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.
This commit is contained in:
Thomas Adamcik 2013-04-01 20:14:04 +02:00
parent 66c067aa96
commit 0535084162
3 changed files with 159 additions and 9 deletions

View File

@ -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

View File

@ -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

View File

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