From 05350841622c5bda67dbc0c10a4f8b531ec79c34 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 20:14:04 +0200 Subject: [PATCH] config: Add config schema and tests. Config schemas are used to group config values and check that each of them is deserialized corretly, that none are missing and that there are no unkown keys present. --- mopidy/exceptions.py | 18 ++++++++ mopidy/utils/config.py | 63 ++++++++++++++++++++++++--- tests/utils/config_test.py | 87 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 159 insertions(+), 9 deletions(-) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index b8d183fb..f8d9d61a 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -20,5 +20,23 @@ class SettingsError(MopidyException): pass +class ConfigError(MopidyException): + def __init__(self, errors): + self._errors = errors + + def __getitem__(self, key): + return self._errors[key] + + def __iter__(self): + return self._errors.iterkeys() + + @property + def message(self): + lines = [] + for key, msg in self._errors.items(): + lines.append('%s: %s' % (key, msg)) + return '\n'.join(lines) + + class OptionalDependencyError(MopidyException): pass diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index efc07d10..e96d3d29 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -4,12 +4,14 @@ import logging import re import socket +from mopidy import exceptions + def validate_choice(value, choices): """Choice validation, normally called in config value's validate().""" if choices is not None and value not in choices : names = ', '.join(repr(c) for c in choices) - raise ValueError('%r must be one of %s.' % (value, names)) + raise ValueError('must be one of %s, not %s.' % (names, value)) def validate_minimum(value, minimum): @@ -68,11 +70,13 @@ class ConfigValue(object): def serialize(self, value): """Convert value back to string for saving.""" + if value is None: + return '' return str(value) def format(self, value): """Format value for display.""" - if self.secret: + if self.secret and value is not None: return '********' return self.serialize(value) @@ -83,9 +87,6 @@ class String(ConfigValue): validate_choice(value, self.choices) return value - def serialize(self, value): - return value.strip() - class Integer(ConfigValue): def deserialize(self, value): @@ -156,3 +157,55 @@ class Port(Integer): super(Port, self).__init__(**kwargs) self.minimum = 1 self.maximum = 2**16 - 1 + + +class ConfigSchema(object): + """Logical group of config values that corespond to a config section. + + Schemas are setup by assigning config keys with config values to instances. + Once setup `convert` can be called with a list of `(key, value)` tuples to + process. For convienience we also support a `format` method that can used + for printing out the converted values. + """ + def __init__(self): + self._schema = {} + self._order = [] + + def __setitem__(self, key, value): + if key not in self._schema: + self._order.append(key) + self._schema[key] = value + + def __getitem__(self, key): + return self._schema[key] + + def format(self, name, values): + lines = ['[%s]' % name] + for key in self._order: + value = values.get(key) + if value is not None: + lines.append('%s = %s' % (key, self._schema[key].format(value))) + return '\n'.join(lines) + + def convert(self, items): + errors = {} + values = {} + + for key, value in items: + try: + if value.strip(): + values[key] = self._schema[key].deserialize(value) + else: # treat blank entries as none + values[key] = None + except KeyError: # not in our schema + errors[key] = 'unknown config key.' + except ValueError as e: # deserialization failed + errors[key] = str(e) + + for key in self._schema: + if key not in values and key not in errors: + errors[key] = 'config key not found.' + + if errors: + raise exceptions.ConfigError(errors) + return values diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index b0ccfe78..b5052bae 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -4,6 +4,7 @@ import logging import mock import socket +from mopidy import exceptions from mopidy.utils import config from tests import unittest @@ -99,10 +100,6 @@ class StringTest(unittest.TestCase): self.assertEqual('foo', value.deserialize('foo')) self.assertRaises(ValueError, value.deserialize, 'foobar') - def test_serialize_strips_whitespace(self): - value = config.String() - self.assertEqual('foo', value.serialize(' foo ')) - def test_format_masks_secrets(self): value = config.String(secret=True) self.assertEqual('********', value.format('s3cret')) @@ -241,3 +238,85 @@ class PortTest(unittest.TestCase): self.assertRaises(ValueError, value.deserialize, '100000') self.assertRaises(ValueError, value.deserialize, '0') self.assertRaises(ValueError, value.deserialize, '-1') + + +class ConfigSchemaTest(unittest.TestCase): + def setUp(self): + self.schema = config.ConfigSchema() + self.schema['foo'] = mock.Mock() + self.schema['bar'] = mock.Mock() + self.schema['baz'] = mock.Mock() + self.values = {'bar': '123', 'foo': '456', 'baz': '678'} + + def test_format(self): + self.schema['foo'].format.return_value = 'qwe' + self.schema['bar'].format.return_value = 'asd' + self.schema['baz'].format.return_value = 'zxc' + + expected = ['[qwerty]', 'foo = qwe', 'bar = asd', 'baz = zxc'] + result = self.schema.format('qwerty', self.values) + self.assertEqual('\n'.join(expected), result) + + def test_format_unkwown_value(self): + self.schema['foo'].format.return_value = 'qwe' + self.schema['bar'].format.return_value = 'asd' + self.schema['baz'].format.return_value = 'zxc' + self.values['unknown'] = 'rty' + + result = self.schema.format('qwerty', self.values) + self.assertNotIn('unknown = rty', result) + + def test_convert(self): + self.schema.convert(self.values.items()) + + def test_convert_with_missing_value(self): + del self.values['foo'] + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('not found', cm.exception['foo']) + + def test_convert_with_extra_value(self): + self.values['extra'] = '123' + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('unknown', cm.exception['extra']) + + def test_convert_with_blank_value(self): + self.values['foo'] = '' + result = self.schema.convert(self.values.items()) + self.assertIsNone(result['foo']) + + def test_convert_with_deserialization_error(self): + self.schema['foo'].deserialize.side_effect = ValueError('failure') + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('failure', cm.exception['foo']) + + def test_convert_with_multiple_deserialization_errors(self): + self.schema['foo'].deserialize.side_effect = ValueError('failure') + self.schema['bar'].deserialize.side_effect = ValueError('other') + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('failure', cm.exception['foo']) + self.assertIn('other', cm.exception['bar']) + + def test_convert_deserialization_unknown_and_missing_errors(self): + self.values['extra'] = '123' + self.schema['bar'].deserialize.side_effect = ValueError('failure') + del self.values['baz'] + + with self.assertRaises(exceptions.ConfigError) as cm: + self.schema.convert(self.values.items()) + + self.assertIn('unknown', cm.exception['extra']) + self.assertNotIn('foo', cm.exception) + self.assertIn('failure', cm.exception['bar']) + self.assertIn('not found', cm.exception['baz'])