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'])