From ba425d8ccb01a4c2dd1c608f5f31dae5a286b2f7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 12:46:05 +0200 Subject: [PATCH 01/17] config: Start adding basic validators + tests for new config values. --- mopidy/utils/config.py | 19 ++++++++++++++ tests/utils/config_test.py | 53 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 mopidy/utils/config.py create mode 100644 tests/utils/config_test.py diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py new file mode 100644 index 00000000..dc813678 --- /dev/null +++ b/mopidy/utils/config.py @@ -0,0 +1,19 @@ +from __future__ import unicode_literals + + +def validate_choice(value, choices): + """Choice validation, normally called in config value's validate().""" + if choices is not None and value not in choices : + raise ValueError('must be one of %s.' % ', '.join(choices)) + + +def validate_minimum(value, minimum): + """Minimum validation, normally called in config value's validate().""" + if minimum is not None and value < minimum: + raise ValueError('must be larger than %s.' % minimum) + + +def validate_maximum(value, maximum): + """Maximum validation, normally called in config value's validate().""" + if maximum is not None and value > maximum: + raise ValueError('must be smaller than %s.' % maximum) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py new file mode 100644 index 00000000..4c8c28a2 --- /dev/null +++ b/tests/utils/config_test.py @@ -0,0 +1,53 @@ +from __future__ import unicode_literals + +from mopidy.utils import config + +from tests import unittest + + +class ValidateChoiceTest(unittest.TestCase): + def test_no_choices_passes(self): + config.validate_choice('foo', None) + + def test_valid_value_passes(self): + config.validate_choice('foo', ['foo', 'bar', 'baz']) + + def test_empty_choices_fails(self): + with self.assertRaises(ValueError): + config.validate_choice('foo', []) + + def test_invalid_value_fails(self): + with self.assertRaises(ValueError): + config.validate_choice('foobar', ['foo', 'bar', 'baz']) + + +class ValidateMinimumTest(unittest.TestCase): + def test_no_minimum_passes(self): + config.validate_minimum(10, None) + + def test_valid_value_passes(self): + config.validate_minimum(10, 5) + + def test_to_small_value_fails(self): + with self.assertRaises(ValueError): + config.validate_minimum(10, 20) + + def test_to_small_value_fails_with_zero_as_minimum(self): + with self.assertRaises(ValueError): + config.validate_minimum(-1, 0) + + +class ValidateMaximumTest(unittest.TestCase): + def test_no_maximum_passes(self): + config.validate_maximum(5, None) + + def test_valid_value_passes(self): + config.validate_maximum(5, 10) + + def test_to_large_value_fails(self): + with self.assertRaises(ValueError): + config.validate_maximum(10, 5) + + def test_to_large_value_fails_with_zero_as_maximum(self): + with self.assertRaises(ValueError): + config.validate_maximum(5, 0) From c22f0f5f9d3769d73c317ba1be78077f04266ef7 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:04:58 +0200 Subject: [PATCH 02/17] config: Add ConfigValue base class and tests. --- mopidy/utils/config.py | 53 ++++++++++++++++++++++++++++++++++++++ tests/utils/config_test.py | 35 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index dc813678..10f1bf78 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -17,3 +17,56 @@ def validate_maximum(value, maximum): """Maximum validation, normally called in config value's validate().""" if maximum is not None and value > maximum: raise ValueError('must be smaller than %s.' % maximum) + + +class ConfigValue(object): + """Represents a config key's value and how to handle it. + + Normally you will only be interacting with sub-classes for config values + that encode either deserialization behavior and/or validation. + + Each config value should be used for the following actions: + + 1. Deserializing from a raw string and validating, raising ValueError on + failure. + 2. Serializing a value back to a string that can be stored in a config. + 3. Formatting a value to a printable form (useful for masking secrets). + + :class:`None` values should not be deserialized, serialized or formatted, + the code interacting with the config should simply skip None config values. + """ + + #: Collection of valid choices for converted value. Must be combined with + #: :function:`validate_choices` in :method:`validate` do any thing. + choices = None + + #: Minimum of converted value. Must be combined with + #: :function:`validate_minimum` in :method:`validate` do any thing. + minimum = None + + #: Maximum of converted value. Must be combined with + #: :function:`validate_maximum` in :method:`validate` do any thing. + maximum = None + + #: Indicate if we should mask the when printing for human consumption. + secret = None + + def __init__(self, choices=None, minimum=None, maximum=None, secret=None): + self.choices = choices + self.minimum = minimum + self.maximum = maximum + self.secret = secret + + def deserialize(self, value): + """Cast raw string to appropriate type.""" + return value + + def serialize(self, value): + """Convert value back to string for saving.""" + return str(value) + + def format(self, value): + """Format value for display.""" + if self.secret: + return '********' + return self.serialize(value) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 4c8c28a2..c1572c78 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -51,3 +51,38 @@ class ValidateMaximumTest(unittest.TestCase): def test_to_large_value_fails_with_zero_as_maximum(self): with self.assertRaises(ValueError): config.validate_maximum(5, 0) + + +class ConfigValueTest(unittest.TestCase): + def test_init(self): + value = config.ConfigValue() + self.assertIsNone(value.choices) + self.assertIsNone(value.minimum) + self.assertIsNone(value.maximum) + self.assertIsNone(value.secret) + + def test_init_with_params(self): + value = config.ConfigValue( + choices=['foo'], minimum=0, maximum=10, secret=True) + self.assertEqual(['foo'], value.choices) + self.assertEqual(0, value.minimum) + self.assertEqual(10, value.maximum) + self.assertEqual(True, value.secret) + + def test_deserialize_passes_through(self): + value = config.ConfigValue() + obj = object() + self.assertEqual(obj, value.deserialize(obj)) + + def test_serialize_converts_to_string(self): + value = config.ConfigValue() + self.assertIsInstance(value.serialize(object()), basestring) + + def test_format_uses_serialize(self): + value = config.ConfigValue() + obj = object() + self.assertEqual(value.serialize(obj), value.format(obj)) + + def test_format_masks_secrets(self): + value = config.ConfigValue(secret=True) + self.assertEqual('********', value.format(object())) From 119644c186091efc956bb219052a173525cf3346 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:20:33 +0200 Subject: [PATCH 03/17] config: Add String config value and tests. --- mopidy/utils/config.py | 10 ++++++++++ tests/utils/config_test.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 10f1bf78..a34fccb6 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -70,3 +70,13 @@ class ConfigValue(object): if self.secret: return '********' return self.serialize(value) + + +class String(ConfigValue): + def deserialize(self, value): + value = value.strip() + validate_choice(value, self.choices) + return value + + def serialize(self, value): + return value.strip() diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index c1572c78..28436b5c 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -86,3 +86,24 @@ class ConfigValueTest(unittest.TestCase): def test_format_masks_secrets(self): value = config.ConfigValue(secret=True) self.assertEqual('********', value.format(object())) + + +class StringTest(unittest.TestCase): + def test_deserialize_strips_whitespace(self): + value = config.String() + self.assertEqual('foo', value.deserialize(' foo ')) + + def test_deserialize_enforces_choices(self): + value = config.String(choices=['foo', 'bar', 'baz']) + + self.assertEqual('foo', value.deserialize('foo')) + with 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')) From 7cb68a41ac6c639cc4818f21cab801b57ca20253 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:29:32 +0200 Subject: [PATCH 04/17] config: Improve validate error messages and fix handling of non-string choices. --- mopidy/utils/config.py | 7 ++++--- tests/utils/config_test.py | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index a34fccb6..2fcdfd94 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -4,19 +4,20 @@ from __future__ import unicode_literals def validate_choice(value, choices): """Choice validation, normally called in config value's validate().""" if choices is not None and value not in choices : - raise ValueError('must be one of %s.' % ', '.join(choices)) + names = ', '.join(repr(c) for c in choices) + raise ValueError('%r must be one of %s.' % (value, names)) def validate_minimum(value, minimum): """Minimum validation, normally called in config value's validate().""" if minimum is not None and value < minimum: - raise ValueError('must be larger than %s.' % minimum) + raise ValueError('%r must be larger than %r.' % (value, minimum)) def validate_maximum(value, maximum): """Maximum validation, normally called in config value's validate().""" if maximum is not None and value > maximum: - raise ValueError('must be smaller than %s.' % maximum) + raise ValueError('%r must be smaller than %r.' % (value, maximum)) class ConfigValue(object): diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 28436b5c..5345e5a4 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -11,6 +11,7 @@ class ValidateChoiceTest(unittest.TestCase): def test_valid_value_passes(self): config.validate_choice('foo', ['foo', 'bar', 'baz']) + config.validate_choice(1, [1, 2, 3]) def test_empty_choices_fails(self): with self.assertRaises(ValueError): @@ -19,6 +20,8 @@ class ValidateChoiceTest(unittest.TestCase): def test_invalid_value_fails(self): with self.assertRaises(ValueError): config.validate_choice('foobar', ['foo', 'bar', 'baz']) + with self.assertRaises(ValueError): + config.validate_choice(5, [1, 2, 3]) class ValidateMinimumTest(unittest.TestCase): @@ -95,7 +98,6 @@ class StringTest(unittest.TestCase): def test_deserialize_enforces_choices(self): value = config.String(choices=['foo', 'bar', 'baz']) - self.assertEqual('foo', value.deserialize('foo')) with self.assertRaises(ValueError): value.deserialize('foobar') From 21d0a938f9d46beb8000366abc423f149a895e9c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:31:15 +0200 Subject: [PATCH 05/17] config: Add Integer ConfigValue and tests. --- mopidy/utils/config.py | 9 +++++++++ tests/utils/config_test.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 2fcdfd94..ab6cb2f7 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -81,3 +81,12 @@ class String(ConfigValue): def serialize(self, value): return value.strip() + + +class Integer(ConfigValue): + def deserialize(self, value): + value = int(value.strip()) + validate_choice(value, self.choices) + validate_minimum(value, self.minimum) + validate_maximum(value, self.maximum) + return value diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 5345e5a4..b973cef1 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -109,3 +109,40 @@ class StringTest(unittest.TestCase): def test_format_masks_secrets(self): value = config.String(secret=True) self.assertEqual('********', value.format('s3cret')) + + +class IntegerTest(unittest.TestCase): + def test_deserialize_converts_to_int(self): + value = config.Integer() + self.assertEqual(123, value.deserialize('123')) + self.assertEqual(0, value.deserialize('0')) + self.assertEqual(-10, value.deserialize('-10')) + + def test_deserialize_fails_on_bad_data(self): + value = config.Integer() + with self.assertRaises(ValueError): + value.deserialize('asd') + with self.assertRaises(ValueError): + value.deserialize('3.14') + + def test_deserialize_enforces_choices(self): + value = config.Integer(choices=[1, 2, 3]) + self.assertEqual(3, value.deserialize('3')) + with self.assertRaises(ValueError): + value.deserialize('5') + + def test_deserialize_enforces_minimum(self): + value = config.Integer(minimum=10) + self.assertEqual(15, value.deserialize('15')) + with self.assertRaises(ValueError): + value.deserialize('5') + + def test_deserialize_enforces_maximum(self): + value = config.Integer(maximum=10) + self.assertEqual(5, value.deserialize('5')) + with self.assertRaises(ValueError): + value.deserialize('15') + + def test_format_masks_secrets(self): + value = config.Integer(secret=True) + self.assertEqual('********', value.format('1337')) From 452cf839c417a3841ae413442addab9daea2bdb9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:38:59 +0200 Subject: [PATCH 06/17] config: Add Boolean ConfigValue and tests. --- mopidy/utils/config.py | 19 +++++++++++++++++++ tests/utils/config_test.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index ab6cb2f7..4009011d 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -90,3 +90,22 @@ class Integer(ConfigValue): validate_minimum(value, self.minimum) validate_maximum(value, self.maximum) return value + + +class Boolean(ConfigValue): + true_values = ('1', 'yes', 'true', 'on') + false_values = ('0', 'no', 'false', 'off') + + def deserialize(self, value): + if value.lower() in self.true_values: + return True + elif value.lower() in self.false_values: + return False + + raise ValueError('invalid value for boolean: %r' % value) + + def serialize(self, value): + if value: + return 'true' + else: + return 'false' diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index b973cef1..1442395f 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -146,3 +146,32 @@ class IntegerTest(unittest.TestCase): def test_format_masks_secrets(self): value = config.Integer(secret=True) self.assertEqual('********', value.format('1337')) + + +class BooleanTest(unittest.TestCase): + def test_deserialize_converts_to_bool(self): + value = config.Boolean() + for true in ('1', 'yes', 'true', 'on'): + self.assertIs(value.deserialize(true), True) + self.assertIs(value.deserialize(true.upper()), True) + self.assertIs(value.deserialize(true.capitalize()), True) + for false in ('0', 'no', 'false', 'off'): + self.assertIs(value.deserialize(false), False) + self.assertIs(value.deserialize(false.upper()), False) + self.assertIs(value.deserialize(false.capitalize()), False) + + def test_deserialize_fails_on_bad_data(self): + value = config.Boolean() + with self.assertRaises(ValueError): + value.deserialize('nope') + with self.assertRaises(ValueError): + value.deserialize('sure') + + def test_serialize_normalises_strings(self): + value = config.Boolean() + self.assertEqual('true', value.serialize(True)) + self.assertEqual('false', value.serialize(False)) + + def test_format_masks_secrets(self): + value = config.Boolean(secret=True) + self.assertEqual('********', value.format('true')) From d46f926f14944e05a3a3a49bb24f05d1bcee003a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 13:46:13 +0200 Subject: [PATCH 07/17] config: Add List ConfigValue and tests. --- mopidy/utils/config.py | 13 +++++++++++++ tests/utils/config_test.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 4009011d..a1a91b88 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import re + def validate_choice(value, choices): """Choice validation, normally called in config value's validate().""" @@ -109,3 +111,14 @@ class Boolean(ConfigValue): return 'true' else: return 'false' + + +class List(ConfigValue): + def deserialize(self, value): + if '\n' in value: + return re.split(r'\s*\n\s*', value.strip()) + else: + return re.split(r',\s*', value.strip()) + + def serialize(self, value): + return '\n '.join(value) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 1442395f..bcc80561 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -175,3 +175,20 @@ class BooleanTest(unittest.TestCase): def test_format_masks_secrets(self): value = config.Boolean(secret=True) self.assertEqual('********', value.format('true')) + + +class ListTest(unittest.TestCase): + def test_deserialize_splits_commas(self): + value = config.List() + self.assertEqual(['foo', 'bar', 'baz'], + value.deserialize('foo, bar,baz')) + + def test_deserialize_splits_newlines(self): + value = config.List() + self.assertEqual(['foo,bar', 'bar', 'baz'], + value.deserialize('foo,bar\nbar\nbaz')) + + def test_serialize_joins_by_newlines(self): + value = config.List() + self.assertRegexpMatches(value.serialize(['foo', 'bar', 'baz']), + r'foo\n\s*bar\n\s*baz') From 6af8b4b0905fea13a80c54cf8a47f8008acca035 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 14:02:28 +0200 Subject: [PATCH 08/17] config: Add LogLevel ConfigValue and tests. --- mopidy/utils/config.py | 17 +++++++++++++++++ tests/utils/config_test.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index a1a91b88..0d6eb928 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import logging import re @@ -122,3 +123,19 @@ class List(ConfigValue): def serialize(self, value): return '\n '.join(value) + + +class LogLevel(ConfigValue): + levels = {'critical' : logging.CRITICAL, + 'error' : logging.ERROR, + 'warning' : logging.WARNING, + 'info' : logging.INFO, + 'debug' : logging.DEBUG} + + def deserialize(self, value): + if value.lower() not in self.levels: + raise ValueError('%r must be one of %s.' % (value, ', '.join(self.levels))) + return self.levels.get(value.lower()) + + def serialize(self, value): + return dict((v, k) for k, v in self.levels.items()).get(value) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index bcc80561..e7583cfb 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import logging + from mopidy.utils import config from tests import unittest @@ -192,3 +194,34 @@ class ListTest(unittest.TestCase): value = config.List() self.assertRegexpMatches(value.serialize(['foo', 'bar', 'baz']), r'foo\n\s*bar\n\s*baz') + + +class BooleanTest(unittest.TestCase): + levels = {'critical' : logging.CRITICAL, + 'error' : logging.ERROR, + 'warning' : logging.WARNING, + 'info' : logging.INFO, + 'debug' : logging.DEBUG} + + def test_deserialize_converts_to_numeric_loglevel(self): + value = config.LogLevel() + for name, level in self.levels.items(): + self.assertEqual(level, value.deserialize(name)) + self.assertEqual(level, value.deserialize(name.upper())) + self.assertEqual(level, value.deserialize(name.capitalize())) + + def test_deserialize_fails_on_bad_data(self): + value = config.LogLevel() + with self.assertRaises(ValueError): + value.deserialize('nope') + with self.assertRaises(ValueError): + value.deserialize('sure') + + def test_serialize_converts_to_string(self): + value = config.LogLevel() + for name, level in self.levels.items(): + self.assertEqual(name, value.serialize(level)) + + def test_serialize_unknown_level(self): + value = config.LogLevel() + self.assertIsNone(value.serialize(1337)) From ab26072dff546ca53cd24c4788ed98c5462592a4 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 14:16:02 +0200 Subject: [PATCH 09/17] config: Switch to non context manager version of assertRaises --- tests/utils/config_test.py | 52 +++++++++++++------------------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index e7583cfb..289d9df8 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -16,14 +16,12 @@ class ValidateChoiceTest(unittest.TestCase): config.validate_choice(1, [1, 2, 3]) def test_empty_choices_fails(self): - with self.assertRaises(ValueError): - config.validate_choice('foo', []) + self.assertRaises(ValueError, config.validate_choice, 'foo', []) def test_invalid_value_fails(self): - with self.assertRaises(ValueError): - config.validate_choice('foobar', ['foo', 'bar', 'baz']) - with self.assertRaises(ValueError): - config.validate_choice(5, [1, 2, 3]) + words = ['foo', 'bar', 'baz'] + self.assertRaises(ValueError, config.validate_choice, 'foobar', words) + self.assertRaises(ValueError, config.validate_choice, 5, [1, 2, 3]) class ValidateMinimumTest(unittest.TestCase): @@ -34,12 +32,10 @@ class ValidateMinimumTest(unittest.TestCase): config.validate_minimum(10, 5) def test_to_small_value_fails(self): - with self.assertRaises(ValueError): - config.validate_minimum(10, 20) + self.assertRaises(ValueError, config.validate_minimum, 10, 20) def test_to_small_value_fails_with_zero_as_minimum(self): - with self.assertRaises(ValueError): - config.validate_minimum(-1, 0) + self.assertRaises(ValueError, config.validate_minimum, -1, 0) class ValidateMaximumTest(unittest.TestCase): @@ -50,12 +46,10 @@ class ValidateMaximumTest(unittest.TestCase): config.validate_maximum(5, 10) def test_to_large_value_fails(self): - with self.assertRaises(ValueError): - config.validate_maximum(10, 5) + self.assertRaises(ValueError, config.validate_maximum, 10, 5) def test_to_large_value_fails_with_zero_as_maximum(self): - with self.assertRaises(ValueError): - config.validate_maximum(5, 0) + self.assertRaises(ValueError, config.validate_maximum, 5, 0) class ConfigValueTest(unittest.TestCase): @@ -101,8 +95,7 @@ class StringTest(unittest.TestCase): def test_deserialize_enforces_choices(self): value = config.String(choices=['foo', 'bar', 'baz']) self.assertEqual('foo', value.deserialize('foo')) - with self.assertRaises(ValueError): - value.deserialize('foobar') + self.assertRaises(ValueError, value.deserialize, 'foobar') def test_serialize_strips_whitespace(self): value = config.String() @@ -122,28 +115,23 @@ class IntegerTest(unittest.TestCase): def test_deserialize_fails_on_bad_data(self): value = config.Integer() - with self.assertRaises(ValueError): - value.deserialize('asd') - with self.assertRaises(ValueError): - value.deserialize('3.14') + self.assertRaises(ValueError, value.deserialize, 'asd') + self.assertRaises(ValueError, value.deserialize, '3.14') def test_deserialize_enforces_choices(self): value = config.Integer(choices=[1, 2, 3]) self.assertEqual(3, value.deserialize('3')) - with self.assertRaises(ValueError): - value.deserialize('5') + self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_minimum(self): value = config.Integer(minimum=10) self.assertEqual(15, value.deserialize('15')) - with self.assertRaises(ValueError): - value.deserialize('5') + self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_maximum(self): value = config.Integer(maximum=10) self.assertEqual(5, value.deserialize('5')) - with self.assertRaises(ValueError): - value.deserialize('15') + self.assertRaises(ValueError, value.deserialize, '15') def test_format_masks_secrets(self): value = config.Integer(secret=True) @@ -164,10 +152,8 @@ class BooleanTest(unittest.TestCase): def test_deserialize_fails_on_bad_data(self): value = config.Boolean() - with self.assertRaises(ValueError): - value.deserialize('nope') - with self.assertRaises(ValueError): - value.deserialize('sure') + self.assertRaises(ValueError, value.deserialize, 'nope') + self.assertRaises(ValueError, value.deserialize, 'sure') def test_serialize_normalises_strings(self): value = config.Boolean() @@ -212,10 +198,8 @@ class BooleanTest(unittest.TestCase): def test_deserialize_fails_on_bad_data(self): value = config.LogLevel() - with self.assertRaises(ValueError): - value.deserialize('nope') - with self.assertRaises(ValueError): - value.deserialize('sure') + self.assertRaises(ValueError, value.deserialize, 'nope') + self.assertRaises(ValueError, value.deserialize, 'sure') def test_serialize_converts_to_string(self): value = config.LogLevel() From 66c067aa96ece4ac83ad651e514b90ee0d2e7078 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 14:21:44 +0200 Subject: [PATCH 10/17] config: Add Hostname and Port ConfigValues and tests. --- mopidy/utils/config.py | 17 +++++++++++++++++ tests/utils/config_test.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index 0d6eb928..efc07d10 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import logging import re +import socket def validate_choice(value, choices): @@ -139,3 +140,19 @@ class LogLevel(ConfigValue): def serialize(self, value): return dict((v, k) for k, v in self.levels.items()).get(value) + + +class Hostname(ConfigValue): + def deserialize(self, value): + try: + socket.getaddrinfo(value, None) + except socket.error: + raise ValueError('must be a resolveable hostname or valid IP.') + return value + + +class Port(Integer): + def __init__(self, **kwargs): + super(Port, self).__init__(**kwargs) + self.minimum = 1 + self.maximum = 2**16 - 1 diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index 289d9df8..b0ccfe78 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals import logging +import mock +import socket from mopidy.utils import config @@ -209,3 +211,33 @@ class BooleanTest(unittest.TestCase): def test_serialize_unknown_level(self): value = config.LogLevel() self.assertIsNone(value.serialize(1337)) + + +class HostnameTest(unittest.TestCase): + @mock.patch('socket.getaddrinfo') + def test_deserialize_checks_addrinfo(self, getaddrinfo_mock): + value = config.Hostname() + value.deserialize('example.com') + getaddrinfo_mock.assert_called_once_with('example.com', None) + + @mock.patch('socket.getaddrinfo') + def test_deserialize_handles_failures(self, getaddrinfo_mock): + value = config.Hostname() + getaddrinfo_mock.side_effect = socket.error + self.assertRaises(ValueError, value.deserialize, 'example.com') + + +class PortTest(unittest.TestCase): + def test_valid_ports(self): + value = config.Port() + self.assertEqual(1, value.deserialize('1')) + self.assertEqual(80, value.deserialize('80')) + self.assertEqual(6600, value.deserialize('6600')) + self.assertEqual(65535, value.deserialize('65535')) + + def test_invalid_ports(self): + value = config.Port() + self.assertRaises(ValueError, value.deserialize, '65536') + self.assertRaises(ValueError, value.deserialize, '100000') + self.assertRaises(ValueError, value.deserialize, '0') + self.assertRaises(ValueError, value.deserialize, '-1') From 05350841622c5bda67dbc0c10a4f8b531ec79c34 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 20:14:04 +0200 Subject: [PATCH 11/17] 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']) From 980792e52745b50a485b2e070f666fd53ed4b9e3 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 20:23:34 +0200 Subject: [PATCH 12/17] config: Add ExtensionConfigSchema. --- mopidy/utils/config.py | 14 ++++++++++++++ tests/utils/config_test.py | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index e96d3d29..d7eed6bf 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -209,3 +209,17 @@ class ConfigSchema(object): if errors: raise exceptions.ConfigError(errors) return values + + +class ExtensionConfigSchema(ConfigSchema): + """Sub-classed ConfigSchema for use in extensions. + + Ensures that `enabled` config value is present and that section name is + prefixed with ext. + """ + def __init__(self): + super(ExtensionConfigSchema, self).__init__() + self['enabled'] = Boolean() + + def format(self, name, values): + return super(ExtensionConfigSchema, self).format('ext.%s' % name, values) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index b5052bae..ae4f4a02 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -320,3 +320,13 @@ class ConfigSchemaTest(unittest.TestCase): self.assertNotIn('foo', cm.exception) self.assertIn('failure', cm.exception['bar']) self.assertIn('not found', cm.exception['baz']) + + +class ExtensionConfigSchemaTest(unittest.TestCase): + def test_schema_includes_enabled(self): + schema = config.ExtensionConfigSchema() + self.assertIsInstance(schema['enabled'], config.Boolean) + + def test_section_name_is_prefixed(self): + schema = config.ExtensionConfigSchema() + self.assertEqual('[ext.foo]', schema.format('foo', {})) From b4c553e201406ad23dc58928a4ba5561c802b4fb Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 20:33:22 +0200 Subject: [PATCH 13/17] config: Add LogLevelConfigSchema. --- mopidy/utils/config.py | 33 +++++++++++++++++++++++++++++++++ tests/utils/config_test.py | 16 ++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index d7eed6bf..a121b277 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -223,3 +223,36 @@ class ExtensionConfigSchema(ConfigSchema): def format(self, name, values): return super(ExtensionConfigSchema, self).format('ext.%s' % name, values) + + +class LogLevelConfigSchema(object): + """Special cased schema for handling a config section with loglevels. + + Expects the config keys to be logger names and the values to be log levels + as understood by the LogLevel config value. Does not sub-class ConfigSchema, + but implements the same interface. + """ + def __init__(self): + self._configvalue = LogLevel() + + def format(self, name, values): + lines = ['[%s]' % name] + for key, value in sorted(values.items()): + if value is not None: + lines.append('%s = %s' % (key, self._configvalue.format(value))) + return '\n'.join(lines) + + def convert(self, items): + errors = {} + values = {} + + for key, value in items: + try: + if value.strip(): + values[key] = self._configvalue.deserialize(value) + except ValueError as e: # deserialization failed + errors[key] = str(e) + + if errors: + raise exceptions.ConfigError(errors) + return values diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index ae4f4a02..fae47111 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -330,3 +330,19 @@ class ExtensionConfigSchemaTest(unittest.TestCase): def test_section_name_is_prefixed(self): schema = config.ExtensionConfigSchema() self.assertEqual('[ext.foo]', schema.format('foo', {})) + + +class LogLevelConfigSchemaTest(unittest.TestCase): + def test_conversion(self): + schema = config.LogLevelConfigSchema() + result = schema.convert([('foo.bar', 'DEBUG'), ('baz', 'INFO')]) + + self.assertEqual(logging.DEBUG, result['foo.bar']) + self.assertEqual(logging.INFO, result['baz']) + + def test_format(self): + schema = config.LogLevelConfigSchema() + expected = ['[levels]', 'baz = info', 'foo.bar = debug'] + result = schema.format('levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO}) + self.assertEqual('\n'.join(expected), result) + From e00b7a63f00f7a0b7c3e75598bd7652a55c4385a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 21:03:16 +0200 Subject: [PATCH 14/17] config: Add mopidy.config to hold config schemas and eventually settings access and loading. --- mopidy/config.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 mopidy/config.py diff --git a/mopidy/config.py b/mopidy/config.py new file mode 100644 index 00000000..2c713a52 --- /dev/null +++ b/mopidy/config.py @@ -0,0 +1,25 @@ +from mopidy.utils import config + +schemas = {} # TODO: use ordered dict? +schemas['logging'] = config.ConfigSchema() +schemas['logging']['config_file'] = config.String() +schemas['logging']['console_format'] = config.String() +schemas['logging']['debug_format'] = config.String() +schemas['logging']['debug_file'] = config.String() +schemas['logging']['debug_thread'] = config.Boolean() + +schemas['logging.levels'] = config.LogLevelConfigSchema() + +schemas['audio'] = config.ConfigSchema() +schemas['audio']['mixer'] = config.String() +schemas['audio']['mixer_track'] = config.String() +schemas['audio']['output'] = config.String() + +# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema +#schemas['audio.outputs'] = config.AudioOutputConfigSchema() + + +def register_schema(name, schema): + if name in schemas: + raise Exception + schemas[name] = schema From 3509ec4b37c8f32beebaecd8a71470c6fbc8c581 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 21:19:38 +0200 Subject: [PATCH 15/17] config: Address review comments. --- mopidy/exceptions.py | 3 --- mopidy/utils/config.py | 30 +++++++++++++++++------------- tests/utils/config_test.py | 10 +++++----- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index f8d9d61a..14d374a0 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -27,9 +27,6 @@ class ConfigError(MopidyException): def __getitem__(self, key): return self._errors[key] - def __iter__(self): - return self._errors.iterkeys() - @property def message(self): lines = [] diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py index a121b277..fad641f1 100644 --- a/mopidy/utils/config.py +++ b/mopidy/utils/config.py @@ -87,6 +87,9 @@ class String(ConfigValue): validate_choice(value, self.choices) return value + def serialize(self, value): + return value.encode('utf-8') + class Integer(ConfigValue): def deserialize(self, value): @@ -121,10 +124,10 @@ class List(ConfigValue): if '\n' in value: return re.split(r'\s*\n\s*', value.strip()) else: - return re.split(r',\s*', value.strip()) + return re.split(r'\s*,\s*', value.strip()) def serialize(self, value): - return '\n '.join(value) + return '\n '.join(v.encode('utf-8') for v in value) class LogLevel(ConfigValue): @@ -148,7 +151,7 @@ class Hostname(ConfigValue): try: socket.getaddrinfo(value, None) except socket.error: - raise ValueError('must be a resolveable hostname or valid IP.') + raise ValueError('must be a resolveable hostname or valid IP') return value @@ -160,13 +163,14 @@ class Port(Integer): class ConfigSchema(object): - """Logical group of config values that corespond to a config section. + """Logical group of config values that correspond 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 + Schemas are set up by assigning config keys with config values to instances. + Once setup :meth:`convert` can be called with a list of `(key, value)` tuples to + process. For convienience we also support :meth:`format` method that can used for printing out the converted values. """ + # TODO: Use collections.OrderedDict once 2.6 support is gone (#344) def __init__(self): self._schema = {} self._order = [] @@ -212,7 +216,7 @@ class ConfigSchema(object): class ExtensionConfigSchema(ConfigSchema): - """Sub-classed ConfigSchema for use in extensions. + """Sub-classed :class:`ConfigSchema` for use in extensions. Ensures that `enabled` config value is present and that section name is prefixed with ext. @@ -229,17 +233,17 @@ class LogLevelConfigSchema(object): """Special cased schema for handling a config section with loglevels. Expects the config keys to be logger names and the values to be log levels - as understood by the LogLevel config value. Does not sub-class ConfigSchema, - but implements the same interface. + as understood by the :class:`LogLevel` config value. Does not sub-class + :class:`ConfigSchema`, but implements the same interface. """ def __init__(self): - self._configvalue = LogLevel() + self._config_value = LogLevel() def format(self, name, values): lines = ['[%s]' % name] for key, value in sorted(values.items()): if value is not None: - lines.append('%s = %s' % (key, self._configvalue.format(value))) + lines.append('%s = %s' % (key, self._config_value.format(value))) return '\n'.join(lines) def convert(self, items): @@ -249,7 +253,7 @@ class LogLevelConfigSchema(object): for key, value in items: try: if value.strip(): - values[key] = self._configvalue.deserialize(value) + values[key] = self._config_value.deserialize(value) except ValueError as e: # deserialization failed errors[key] = str(e) diff --git a/tests/utils/config_test.py b/tests/utils/config_test.py index fae47111..a98c37b5 100644 --- a/tests/utils/config_test.py +++ b/tests/utils/config_test.py @@ -182,11 +182,11 @@ class ListTest(unittest.TestCase): class BooleanTest(unittest.TestCase): - levels = {'critical' : logging.CRITICAL, - 'error' : logging.ERROR, - 'warning' : logging.WARNING, - 'info' : logging.INFO, - 'debug' : logging.DEBUG} + levels = {'critical': logging.CRITICAL, + 'error': logging.ERROR, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG} def test_deserialize_converts_to_numeric_loglevel(self): value = config.LogLevel() From 5e608c18dcf2f36b18898b4256b511c94228ad4c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 21:26:48 +0200 Subject: [PATCH 16/17] config: re-add ConfigError.__iter__ --- mopidy/exceptions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py index 0c370c8a..23aa3fb8 100644 --- a/mopidy/exceptions.py +++ b/mopidy/exceptions.py @@ -27,6 +27,9 @@ class ConfigError(MopidyException): def __getitem__(self, key): return self._errors[key] + def __iter__(self): + return self._errors.iterkeys() + @property def message(self): lines = [] From 5283d1e2b2c462ef2569967d13699b1fbae34863 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 1 Apr 2013 21:35:01 +0200 Subject: [PATCH 17/17] main: Add ConfigError test. --- tests/exceptions_test.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py index 2bc838d7..12a18338 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -23,3 +23,13 @@ class ExceptionsTest(unittest.TestCase): def test_extension_error_is_a_mopidy_exception(self): self.assert_(issubclass( exceptions.ExtensionError, exceptions.MopidyException)) + + def test_config_error_is_a_mopidy_exception(self): + self.assert_(issubclass( + exceptions.ConfigError, exceptions.MopidyException)) + + def test_config_error_provides_getitem(self): + exception = exceptions.ConfigError({'field1': 'msg1', 'field2': 'msg2'}) + self.assertEqual('msg1', exception['field1']) + self.assertEqual('msg2', exception['field2']) + self.assertItemsEqual(['field1', 'field2'], exception)