From 4dd2a56f675675d391aed15a100be3bf99ccec7d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 10 Apr 2013 21:30:47 +0200 Subject: [PATCH 01/16] config: Convert mopidy.config to module --- mopidy/{config.py => config/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mopidy/{config.py => config/__init__.py} (100%) diff --git a/mopidy/config.py b/mopidy/config/__init__.py similarity index 100% rename from mopidy/config.py rename to mopidy/config/__init__.py From 5816b5e099372fe9ff95b1c0becccf92b320148b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 10 Apr 2013 21:42:58 +0200 Subject: [PATCH 02/16] config: Remove register schema as it is not used --- mopidy/config/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 88fc3419..4179d112 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -43,9 +43,3 @@ config_schemas['proxy']['password'] = config.String(optional=True, secret=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() - - -def register_schema(name, schema): - if name in config_schemas: - raise Exception - config_schemas[name] = schema From d90a977a3bf78c747f7aec119309dc4d216a62bf Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Wed, 10 Apr 2013 22:47:37 +0200 Subject: [PATCH 03/16] config: Move everything to mopidy.config sub-modules --- mopidy/__main__.py | 1 + mopidy/backends/local/__init__.py | 4 +- mopidy/backends/spotify/__init__.py | 7 +- mopidy/backends/stream/__init__.py | 4 +- mopidy/config/__init__.py | 29 +- mopidy/config/schemas.py | 137 +++++++ mopidy/config/validators.py | 28 ++ mopidy/config/values.py | 212 ++++++++++ mopidy/ext.py | 2 +- mopidy/frontends/http/__init__.py | 4 +- mopidy/frontends/mpd/__init__.py | 4 +- mopidy/frontends/mpris/__init__.py | 4 +- mopidy/frontends/scrobbler/__init__.py | 4 +- mopidy/utils/config.py | 371 ------------------ tests/config/schemas_test.py | 127 ++++++ tests/config/validator_tests.py | 66 ++++ .../config_test.py => config/values_test.py} | 272 +++---------- tests/ext_test.py | 5 +- 18 files changed, 651 insertions(+), 630 deletions(-) create mode 100644 mopidy/config/schemas.py create mode 100644 mopidy/config/validators.py create mode 100644 mopidy/config/values.py delete mode 100644 mopidy/utils/config.py create mode 100644 tests/config/schemas_test.py create mode 100644 tests/config/validator_tests.py rename tests/{utils/config_test.py => config/values_test.py} (52%) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 069c883e..1bfcfbcf 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -30,6 +30,7 @@ sys.path.insert( from mopidy import exceptions, ext from mopidy.audio import Audio from mopidy.config import default_config, config_schemas +from mopidy import config as config_utils # TODO: cleanup from mopidy.core import Core from mopidy.utils import deps, log, path, process, versioning diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 0367cf15..4c5eb2a2 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.utils import config, formatting +from mopidy import config, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index c26a42e7..ec55888c 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,9 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.exceptions import ExtensionError -from mopidy.utils import config, formatting +from mopidy import config, exceptions, ext +from mopidy.utils import formatting default_config = """ @@ -96,7 +95,7 @@ class Extension(ext.Extension): try: import spotify # noqa except ImportError as e: - raise ExtensionError('pyspotify library not found', e) + raise exceptions.ExtensionError('pyspotify library not found', e) def get_backend_classes(self): from .actor import SpotifyBackend diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 11918500..60817030 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.utils import config, formatting +from mopidy import config, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 4179d112..82fc839e 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals -from mopidy.utils import config +from mopidy.config.schemas import * +from mopidy.config.values import * default_config = """ @@ -24,22 +25,22 @@ password = """ config_schemas = {} # TODO: use ordered dict? -config_schemas['logging'] = config.ConfigSchema() -config_schemas['logging']['console_format'] = config.String() -config_schemas['logging']['debug_format'] = config.String() -config_schemas['logging']['debug_file'] = config.Path() +config_schemas['logging'] = ConfigSchema() +config_schemas['logging']['console_format'] = String() +config_schemas['logging']['debug_format'] = String() +config_schemas['logging']['debug_file'] = Path() -config_schemas['logging.levels'] = config.LogLevelConfigSchema() +config_schemas['logging.levels'] = LogLevelConfigSchema() -config_schemas['audio'] = config.ConfigSchema() -config_schemas['audio']['mixer'] = config.String() -config_schemas['audio']['mixer_track'] = config.String(optional=True) -config_schemas['audio']['output'] = config.String() +config_schemas['audio'] = ConfigSchema() +config_schemas['audio']['mixer'] = String() +config_schemas['audio']['mixer_track'] = String(optional=True) +config_schemas['audio']['output'] = String() -config_schemas['proxy'] = config.ConfigSchema() -config_schemas['proxy']['hostname'] = config.Hostname(optional=True) -config_schemas['proxy']['username'] = config.String(optional=True) -config_schemas['proxy']['password'] = config.String(optional=True, secret=True) +config_schemas['proxy'] = ConfigSchema() +config_schemas['proxy']['hostname'] = Hostname(optional=True) +config_schemas['proxy']['username'] = String(optional=True) +config_schemas['proxy']['password'] = String(optional=True, secret=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py new file mode 100644 index 00000000..15d0bfe3 --- /dev/null +++ b/mopidy/config/schemas.py @@ -0,0 +1,137 @@ +from mopidy import exceptions + +from mopidy.config import values + + +def _did_you_mean(name, choices): + """Suggest most likely setting based on levenshtein.""" + if not choices: + return None + + name = name.lower() + candidates = [(_levenshtein(name, c), c) for c in choices] + candidates.sort() + + if candidates[0][0] <= 3: + return candidates[0][1] + return None + + +def _levenshtein(a, b): + """Calculates the Levenshtein distance between a and b.""" + n, m = len(a), len(b) + if n > m: + return _levenshtein(b, a) + + current = xrange(n + 1) + for i in xrange(1, m + 1): + previous, current = current, [i] + [0] * n + for j in xrange(1, n + 1): + add, delete = previous[j] + 1, current[j - 1] + 1 + change = previous[j - 1] + if a[j - 1] != b[i - 1]: + change += 1 + current[j] = min(add, delete, change) + return current[n] + + + + +class ConfigSchema(object): + """Logical group of config values that correspond to a config section. + + 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 = [] + + 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): + # TODO: should the output be encoded utf-8 since we use that in + # serialize for strings? + 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: + values[key] = self._schema[key].deserialize(value) + except KeyError: # not in our schema + errors[key] = 'unknown config key.' + suggestion = _did_you_mean(key, self._schema.keys()) + if suggestion: + errors[key] += ' Did you mean %s?' % suggestion + 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 + + +class ExtensionConfigSchema(ConfigSchema): + """Sub-classed :class:`ConfigSchema` for use in extensions. + + Ensures that ``enabled`` config value is present. + """ + def __init__(self): + super(ExtensionConfigSchema, self).__init__() + self['enabled'] = values.Boolean() + + +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 :class:`LogLevel` config value. Does not sub-class + :class:`ConfigSchema`, but implements the same interface. + """ + def __init__(self): + self._config_value = values.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._config_value.format(value))) + return '\n'.join(lines) + + def convert(self, items): + errors = {} + values = {} + + for key, value in items: + try: + if value.strip(): + values[key] = self._config_value.deserialize(value) + except ValueError as e: # deserialization failed + errors[key] = str(e) + + if errors: + raise exceptions.ConfigError(errors) + return values diff --git a/mopidy/config/validators.py b/mopidy/config/validators.py new file mode 100644 index 00000000..ab7282be --- /dev/null +++ b/mopidy/config/validators.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals + +# TODO: add validate regexp? + +def validate_required(value, required): + """Required validation, normally called in config value's validate() on the + raw string, _not_ the converted value.""" + if required and not value.strip(): + raise ValueError('must be set.') + + +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('must be one of %s, not %s.' % (names, value)) + + +def validate_minimum(value, minimum): + """Minimum validation, normally called in config value's validate().""" + if minimum is not None and value < 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('%r must be smaller than %r.' % (value, maximum)) diff --git a/mopidy/config/values.py b/mopidy/config/values.py new file mode 100644 index 00000000..0fe40961 --- /dev/null +++ b/mopidy/config/values.py @@ -0,0 +1,212 @@ +from __future__ import unicode_literals + +import logging +import re +import socket + +from mopidy.utils import path +from mopidy.config import validators + + +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 this field is required. + optional = None + + #: Indicate if we should mask the when printing for human consumption. + secret = None + + def __init__(self, **kwargs): + self.choices = kwargs.get('choices') + self.minimum = kwargs.get('minimum') + self.maximum = kwargs.get('maximum') + self.optional = kwargs.get('optional') + self.secret = kwargs.get('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 and value is not None: + return '********' + return self.serialize(value) + + +class String(ConfigValue): + """String values. + + Supports: optional, choices and secret. + """ + def deserialize(self, value): + value = value.strip() + validators.validate_required(value, not self.optional) + validators.validate_choice(value, self.choices) + if not value: + return None + return value + + def serialize(self, value): + return value.encode('utf-8').encode('string-escape') + + +class Integer(ConfigValue): + """Integer values. + + Supports: choices, minimum, maximum and secret. + """ + def deserialize(self, value): + value = int(value) + validators.validate_choice(value, self.choices) + validators.validate_minimum(value, self.minimum) + validators.validate_maximum(value, self.maximum) + return value + + +class Boolean(ConfigValue): + """Boolean values. + + Supports: secret. + """ + 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' + + +class List(ConfigValue): + """List values split by comma or newline. + + Supports: optional and secret. + """ + def deserialize(self, value): + validators.validate_required(value, not self.optional) + if '\n' in value: + values = re.split(r'\s*\n\s*', value.strip()) + else: + values = re.split(r'\s*,\s*', value.strip()) + return tuple([v for v in values if v]) + + def serialize(self, value): + return '\n ' + '\n '.join(v.encode('utf-8') for v in value) + + +class LogLevel(ConfigValue): + """Log level values. + + Supports: secret. + """ + levels = { + 'critical': logging.CRITICAL, + 'error': logging.ERROR, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG, + } + + def deserialize(self, value): + validators.validate_choice(value.lower(), self.levels.keys()) + return self.levels.get(value.lower()) + + def serialize(self, value): + return dict((v, k) for k, v in self.levels.items()).get(value) + + +class Hostname(ConfigValue): + """Hostname values. + + Supports: optional and secret. + """ + def deserialize(self, value): + validators.validate_required(value, not self.optional) + if not value.strip(): + return None + try: + socket.getaddrinfo(value, None) + except socket.error: + raise ValueError('must be a resolveable hostname or valid IP') + return value + + +class Port(Integer): + """Port values limited to 1-65535. + + Supports: choices and secret. + """ + # TODO: consider probing if port is free or not? + def __init__(self, **kwargs): + super(Port, self).__init__(**kwargs) + self.minimum = 1 + self.maximum = 2 ** 16 - 1 + + +class ExpandedPath(bytes): + def __new__(self, value): + expanded = path.expand_path(value) + return super(ExpandedPath, self).__new__(self, expanded) + + def __init__(self, value): + self.original = value + + +class Path(ConfigValue): + """File system path that will be expanded with mopidy.utils.path.expand_path + + Supports: optional, choices and secret. + """ + def deserialize(self, value): + value = value.strip() + validators.validate_required(value, not self.optional) + validators.validate_choice(value, self.choices) + if not value: + return None + return ExpandedPath(value) + + def serialize(self, value): + if isinstance(value, ExpandedPath): + return value.original + return value diff --git a/mopidy/ext.py b/mopidy/ext.py index 7fee6014..1d554e72 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -4,7 +4,7 @@ import logging import pkg_resources from mopidy import exceptions -from mopidy.utils import config as config_utils +from mopidy import config as config_utils logger = logging.getLogger('mopidy.ext') diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 32d55f23..1cdbc1fb 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import exceptions, ext -from mopidy.utils import config, formatting +from mopidy import config, exceptions, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 69297374..a5e86de9 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import ext -from mopidy.utils import config, formatting +from mopidy import config, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 5be9c6cf..d41f10fe 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals import os import mopidy -from mopidy import exceptions, ext -from mopidy.utils import formatting, config +from mopidy import config, exceptions, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py index f3127040..55784c7e 100644 --- a/mopidy/frontends/scrobbler/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import mopidy -from mopidy import exceptions, ext -from mopidy.utils import config, formatting +from mopidy import config, exceptions, ext +from mopidy.utils import formatting default_config = """ diff --git a/mopidy/utils/config.py b/mopidy/utils/config.py deleted file mode 100644 index 09278535..00000000 --- a/mopidy/utils/config.py +++ /dev/null @@ -1,371 +0,0 @@ -from __future__ import unicode_literals - -import logging -import re -import socket - -from mopidy import exceptions -from mopidy.utils import path - - -def validate_required(value, required): - """Required validation, normally called in config value's validate() on the - raw string, _not_ the converted value.""" - if required and not value.strip(): - raise ValueError('must be set.') - - -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('must be one of %s, not %s.' % (names, value)) - - -def validate_minimum(value, minimum): - """Minimum validation, normally called in config value's validate().""" - if minimum is not None and value < 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('%r must be smaller than %r.' % (value, maximum)) - - -# TODO: move this and levenshtein to a more appropriate class. -def did_you_mean(name, choices): - """Suggest most likely setting based on levenshtein.""" - if not choices: - return None - - name = name.lower() - candidates = [(levenshtein(name, c), c) for c in choices] - candidates.sort() - - if candidates[0][0] <= 3: - return candidates[0][1] - return None - - -def levenshtein(a, b): - """Calculates the Levenshtein distance between a and b.""" - n, m = len(a), len(b) - if n > m: - return levenshtein(b, a) - - current = xrange(n + 1) - for i in xrange(1, m + 1): - previous, current = current, [i] + [0] * n - for j in xrange(1, n + 1): - add, delete = previous[j] + 1, current[j - 1] + 1 - change = previous[j - 1] - if a[j - 1] != b[i - 1]: - change += 1 - current[j] = min(add, delete, change) - return current[n] - - -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 this field is required. - optional = None - - #: Indicate if we should mask the when printing for human consumption. - secret = None - - def __init__(self, **kwargs): - self.choices = kwargs.get('choices') - self.minimum = kwargs.get('minimum') - self.maximum = kwargs.get('maximum') - self.optional = kwargs.get('optional') - self.secret = kwargs.get('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 and value is not None: - return '********' - return self.serialize(value) - - -class String(ConfigValue): - """String values. - - Supports: optional, choices and secret. - """ - def deserialize(self, value): - value = value.strip() - validate_required(value, not self.optional) - validate_choice(value, self.choices) - if not value: - return None - return value - - def serialize(self, value): - return value.encode('utf-8').encode('string-escape') - - -class Integer(ConfigValue): - """Integer values. - - Supports: choices, minimum, maximum and secret. - """ - def deserialize(self, value): - value = int(value) - validate_choice(value, self.choices) - validate_minimum(value, self.minimum) - validate_maximum(value, self.maximum) - return value - - -class Boolean(ConfigValue): - """Boolean values. - - Supports: secret. - """ - 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' - - -class List(ConfigValue): - """List values split by comma or newline. - - Supports: optional and secret. - """ - def deserialize(self, value): - validate_required(value, not self.optional) - if '\n' in value: - values = re.split(r'\s*\n\s*', value.strip()) - else: - values = re.split(r'\s*,\s*', value.strip()) - return tuple([v for v in values if v]) - - def serialize(self, value): - return '\n ' + '\n '.join(v.encode('utf-8') for v in value) - - -class LogLevel(ConfigValue): - """Log level values. - - Supports: secret. - """ - levels = { - 'critical': logging.CRITICAL, - 'error': logging.ERROR, - 'warning': logging.WARNING, - 'info': logging.INFO, - 'debug': logging.DEBUG, - } - - def deserialize(self, value): - validate_choice(value.lower(), self.levels.keys()) - return self.levels.get(value.lower()) - - def serialize(self, value): - return dict((v, k) for k, v in self.levels.items()).get(value) - - -class Hostname(ConfigValue): - """Hostname values. - - Supports: optional and secret. - """ - def deserialize(self, value): - validate_required(value, not self.optional) - if not value.strip(): - return None - try: - socket.getaddrinfo(value, None) - except socket.error: - raise ValueError('must be a resolveable hostname or valid IP') - return value - - -class Port(Integer): - """Port values limited to 1-65535. - - Supports: choices and secret. - """ - # TODO: consider probing if port is free or not? - def __init__(self, **kwargs): - super(Port, self).__init__(**kwargs) - self.minimum = 1 - self.maximum = 2 ** 16 - 1 - - -class ExpandedPath(bytes): - def __new__(self, value): - expanded = path.expand_path(value) - return super(ExpandedPath, self).__new__(self, expanded) - - def __init__(self, value): - self.original = value - - -class Path(ConfigValue): - """File system path that will be expanded with mopidy.utils.path.expand_path - - Supports: optional, choices and secret. - """ - def deserialize(self, value): - value = value.strip() - validate_required(value, not self.optional) - validate_choice(value, self.choices) - if not value: - return None - return ExpandedPath(value) - - def serialize(self, value): - if isinstance(value, ExpandedPath): - return value.original - return value - - -class ConfigSchema(object): - """Logical group of config values that correspond to a config section. - - 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 = [] - - 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): - # TODO: should the output be encoded utf-8 since we use that in - # serialize for strings? - 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: - values[key] = self._schema[key].deserialize(value) - except KeyError: # not in our schema - errors[key] = 'unknown config key.' - suggestion = did_you_mean(key, self._schema.keys()) - if suggestion: - errors[key] += ' Did you mean %s?' % suggestion - 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 - - -class ExtensionConfigSchema(ConfigSchema): - """Sub-classed :class:`ConfigSchema` for use in extensions. - - Ensures that ``enabled`` config value is present. - """ - def __init__(self): - super(ExtensionConfigSchema, self).__init__() - self['enabled'] = Boolean() - - -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 :class:`LogLevel` config value. Does not sub-class - :class:`ConfigSchema`, but implements the same interface. - """ - def __init__(self): - 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._config_value.format(value))) - return '\n'.join(lines) - - def convert(self, items): - errors = {} - values = {} - - for key, value in items: - try: - if value.strip(): - values[key] = self._config_value.deserialize(value) - except ValueError as e: # deserialization failed - errors[key] = str(e) - - if errors: - raise exceptions.ConfigError(errors) - return values diff --git a/tests/config/schemas_test.py b/tests/config/schemas_test.py new file mode 100644 index 00000000..4920bbfe --- /dev/null +++ b/tests/config/schemas_test.py @@ -0,0 +1,127 @@ +from __future__ import unicode_literals + +import logging +import mock + +from mopidy import exceptions +from mopidy.config import schemas + +from tests import unittest + + +class ConfigSchemaTest(unittest.TestCase): + def setUp(self): + self.schema = schemas.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_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']) + + +class ExtensionConfigSchemaTest(unittest.TestCase): + def test_schema_includes_enabled(self): + schema = schemas.ExtensionConfigSchema() + self.assertIsInstance(schema['enabled'], values.Boolean) + + +class LogLevelConfigSchemaTest(unittest.TestCase): + def test_conversion(self): + schema = schemas.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 = schemas.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) + + +class DidYouMeanTest(unittest.TestCase): + def testSuggestoins(self): + choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') + + suggestion = schemas._did_you_mean('bitrate', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = schemas._did_you_mean('bitrote', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = schemas._did_you_mean('Bitrot', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = schemas._did_you_mean('BTROT', choices) + self.assertEqual(suggestion, 'bitrate') + + suggestion = schemas._did_you_mean('btro', choices) + self.assertEqual(suggestion, None) diff --git a/tests/config/validator_tests.py b/tests/config/validator_tests.py new file mode 100644 index 00000000..4c0ff70f --- /dev/null +++ b/tests/config/validator_tests.py @@ -0,0 +1,66 @@ +from __future__ import unicode_literals + +from mopidy.config import validators + +from tests import unittest + + + +class ValidateChoiceTest(unittest.TestCase): + def test_no_choices_passes(self): + validators.validate_choice('foo', None) + + def test_valid_value_passes(self): + validators.validate_choice('foo', ['foo', 'bar', 'baz']) + validators.validate_choice(1, [1, 2, 3]) + + def test_empty_choices_fails(self): + self.assertRaises(ValueError,validators.validate_choice, 'foo', []) + + def test_invalid_value_fails(self): + words = ['foo', 'bar', 'baz'] + self.assertRaises(ValueError, validators.validate_choice, 'foobar', words) + self.assertRaises(ValueError, validators.validate_choice, 5, [1, 2, 3]) + + +class ValidateMinimumTest(unittest.TestCase): + def test_no_minimum_passes(self): + validators.validate_minimum(10, None) + + def test_valid_value_passes(self): + validators.validate_minimum(10, 5) + + def test_to_small_value_fails(self): + self.assertRaises(ValueError, validators.validate_minimum, 10, 20) + + def test_to_small_value_fails_with_zero_as_minimum(self): + self.assertRaises(ValueError, validators.validate_minimum, -1, 0) + + +class ValidateMaximumTest(unittest.TestCase): + def test_no_maximum_passes(self): + validators.validate_maximum(5, None) + + def test_valid_value_passes(self): + validators.validate_maximum(5, 10) + + def test_to_large_value_fails(self): + self.assertRaises(ValueError, validators.validate_maximum, 10, 5) + + def test_to_large_value_fails_with_zero_as_maximum(self): + self.assertRaises(ValueError, validators.validate_maximum, 5, 0) + + +class ValidateRequiredTest(unittest.TestCase): + def test_passes_when_false(self): + validators.validate_required('foo', False) + validators.validate_required('', False) + validators.validate_required(' ', False) + + def test_passes_when_required_and_set(self): + validators.validate_required('foo', True) + validators.validate_required(' foo ', True) + + def test_blocks_when_required_and_emtpy(self): + self.assertRaises(ValueError, validators.validate_required, '', True) + self.assertRaises(ValueError, validators.validate_required, ' ', True) diff --git a/tests/utils/config_test.py b/tests/config/values_test.py similarity index 52% rename from tests/utils/config_test.py rename to tests/config/values_test.py index bf26b2e7..442fffbb 100644 --- a/tests/utils/config_test.py +++ b/tests/config/values_test.py @@ -5,74 +5,14 @@ import mock import socket from mopidy import exceptions -from mopidy.utils import config +from mopidy.config import values 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']) - config.validate_choice(1, [1, 2, 3]) - - def test_empty_choices_fails(self): - self.assertRaises(ValueError, config.validate_choice, 'foo', []) - - def test_invalid_value_fails(self): - 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): - 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): - self.assertRaises(ValueError, config.validate_minimum, 10, 20) - - def test_to_small_value_fails_with_zero_as_minimum(self): - 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): - self.assertRaises(ValueError, config.validate_maximum, 10, 5) - - def test_to_large_value_fails_with_zero_as_maximum(self): - self.assertRaises(ValueError, config.validate_maximum, 5, 0) - - -class ValidateRequiredTest(unittest.TestCase): - def test_passes_when_false(self): - config.validate_required('foo', False) - config.validate_required('', False) - config.validate_required(' ', False) - - def test_passes_when_required_and_set(self): - config.validate_required('foo', True) - config.validate_required(' foo ', True) - - def test_blocks_when_required_and_emtpy(self): - self.assertRaises(ValueError, config.validate_required, '', True) - self.assertRaises(ValueError, config.validate_required, ' ', True) - - class ConfigValueTest(unittest.TestCase): def test_init(self): - value = config.ConfigValue() + value = values.ConfigValue() self.assertIsNone(value.choices) self.assertIsNone(value.maximum) self.assertIsNone(value.minimum) @@ -82,7 +22,7 @@ class ConfigValueTest(unittest.TestCase): def test_init_with_params(self): kwargs = {'choices': ['foo'], 'minimum': 0, 'maximum': 10, 'secret': True, 'optional': True} - value = config.ConfigValue(**kwargs) + value = values.ConfigValue(**kwargs) self.assertEqual(['foo'], value.choices) self.assertEqual(0, value.minimum) self.assertEqual(10, value.maximum) @@ -90,90 +30,90 @@ class ConfigValueTest(unittest.TestCase): self.assertEqual(True, value.secret) def test_deserialize_passes_through(self): - value = config.ConfigValue() + value = values.ConfigValue() obj = object() self.assertEqual(obj, value.deserialize(obj)) def test_serialize_conversion_to_string(self): - value = config.ConfigValue() + value = values.ConfigValue() self.assertIsInstance(value.serialize(object()), basestring) def test_format_uses_serialize(self): - value = config.ConfigValue() + value = values.ConfigValue() obj = object() self.assertEqual(value.serialize(obj), value.format(obj)) def test_format_masks_secrets(self): - value = config.ConfigValue(secret=True) + value = values.ConfigValue(secret=True) self.assertEqual('********', value.format(object())) class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = config.String() + value = values.String() self.assertEqual('foo', value.deserialize(' foo ')) def test_deserialize_enforces_choices(self): - value = config.String(choices=['foo', 'bar', 'baz']) + value = values.String(choices=['foo', 'bar', 'baz']) self.assertEqual('foo', value.deserialize('foo')) self.assertRaises(ValueError, value.deserialize, 'foobar') def test_deserialize_enforces_required(self): - value = config.String() + value = values.String() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = config.String(optional=True) + value = values.String(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) def test_serialize_string_escapes(self): - value = config.String() + value = values.String() self.assertEqual(r'\r\n\t', value.serialize('\r\n\t')) def test_format_masks_secrets(self): - value = config.String(secret=True) + value = values.String(secret=True) self.assertEqual('********', value.format('s3cret')) class IntegerTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = config.Integer() + value = values.Integer() self.assertEqual(123, value.deserialize('123')) self.assertEqual(0, value.deserialize('0')) self.assertEqual(-10, value.deserialize('-10')) def test_deserialize_conversion_failure(self): - value = config.Integer() + value = values.Integer() self.assertRaises(ValueError, value.deserialize, 'asd') self.assertRaises(ValueError, value.deserialize, '3.14') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_enforces_choices(self): - value = config.Integer(choices=[1, 2, 3]) + value = values.Integer(choices=[1, 2, 3]) self.assertEqual(3, value.deserialize('3')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_minimum(self): - value = config.Integer(minimum=10) + value = values.Integer(minimum=10) self.assertEqual(15, value.deserialize('15')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_maximum(self): - value = config.Integer(maximum=10) + value = values.Integer(maximum=10) self.assertEqual(5, value.deserialize('5')) self.assertRaises(ValueError, value.deserialize, '15') def test_format_masks_secrets(self): - value = config.Integer(secret=True) + value = values.Integer(secret=True) self.assertEqual('********', value.format('1337')) class BooleanTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = config.Boolean() + value = values.Boolean() for true in ('1', 'yes', 'true', 'on'): self.assertIs(value.deserialize(true), True) self.assertIs(value.deserialize(true.upper()), True) @@ -184,24 +124,24 @@ class BooleanTest(unittest.TestCase): self.assertIs(value.deserialize(false.capitalize()), False) def test_deserialize_conversion_failure(self): - value = config.Boolean() + value = values.Boolean() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') def test_serialize(self): - value = config.Boolean() + value = values.Boolean() self.assertEqual('true', value.serialize(True)) self.assertEqual('false', value.serialize(False)) def test_format_masks_secrets(self): - value = config.Boolean(secret=True) + value = values.Boolean(secret=True) self.assertEqual('********', value.format('true')) class ListTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = config.List() + value = values.List() expected = ('foo', 'bar', 'baz') self.assertEqual(expected, value.deserialize('foo, bar ,baz ')) @@ -210,17 +150,17 @@ class ListTest(unittest.TestCase): self.assertEqual(expected, value.deserialize(' foo,bar\nbar\nbaz')) def test_deserialize_enforces_required(self): - value = config.List() + value = values.List() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = config.List(optional=True) + value = values.List(optional=True) self.assertEqual(tuple(), value.deserialize('')) self.assertEqual(tuple(), value.deserialize(' ')) def test_serialize(self): - value = config.List() + value = values.List() result = value.serialize(('foo', 'bar', 'baz')) self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') @@ -233,21 +173,21 @@ class BooleanTest(unittest.TestCase): 'debug': logging.DEBUG} def test_deserialize_conversion_success(self): - value = config.LogLevel() + value = values.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_conversion_failure(self): - value = config.LogLevel() + value = values.LogLevel() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_serialize(self): - value = config.LogLevel() + value = values.LogLevel() for name, level in self.levels.items(): self.assertEqual(name, value.serialize(level)) self.assertIsNone(value.serialize(1337)) @@ -256,26 +196,26 @@ class BooleanTest(unittest.TestCase): class HostnameTest(unittest.TestCase): @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_success(self, getaddrinfo_mock): - value = config.Hostname() + value = values.Hostname() value.deserialize('example.com') getaddrinfo_mock.assert_called_once_with('example.com', None) @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_failure(self, getaddrinfo_mock): - value = config.Hostname() + value = values.Hostname() getaddrinfo_mock.side_effect = socket.error self.assertRaises(ValueError, value.deserialize, 'example.com') @mock.patch('socket.getaddrinfo') def test_deserialize_enforces_required(self, getaddrinfo_mock): - value = config.Hostname() + value = values.Hostname() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') self.assertEqual(0, getaddrinfo_mock.call_count) @mock.patch('socket.getaddrinfo') def test_deserialize_respects_optional(self, getaddrinfo_mock): - value = config.Hostname(optional=True) + value = values.Hostname(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) self.assertEqual(0, getaddrinfo_mock.call_count) @@ -283,14 +223,14 @@ class HostnameTest(unittest.TestCase): class PortTest(unittest.TestCase): def test_valid_ports(self): - value = config.Port() + value = values.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() + value = values.Port() self.assertRaises(ValueError, value.deserialize, '65536') self.assertRaises(ValueError, value.deserialize, '100000') self.assertRaises(ValueError, value.deserialize, '0') @@ -300,166 +240,48 @@ class PortTest(unittest.TestCase): class ExpandedPathTest(unittest.TestCase): def test_is_bytes(self): - self.assertIsInstance(config.ExpandedPath('/tmp'), bytes) + self.assertIsInstance(values.ExpandedPath('/tmp'), bytes) @mock.patch('mopidy.utils.path.expand_path') def test_defaults_to_expanded(self, expand_path_mock): expand_path_mock.return_value = 'expanded_path' - self.assertEqual('expanded_path', config.ExpandedPath('~')) + self.assertEqual('expanded_path', values.ExpandedPath('~')) @mock.patch('mopidy.utils.path.expand_path') def test_orginal_stores_unexpanded(self, expand_path_mock): - self.assertEqual('~', config.ExpandedPath('~').original) + self.assertEqual('~', values.ExpandedPath('~').original) class PathTest(unittest.TestCase): def test_deserialize_conversion_success(self): - result = config.Path().deserialize('/foo') + result = values.Path().deserialize('/foo') self.assertEqual('/foo', result) - self.assertIsInstance(result, config.ExpandedPath) + self.assertIsInstance(result, values.ExpandedPath) self.assertIsInstance(result, bytes) def test_deserialize_enforces_choices(self): - value = config.Path(choices=['/foo', '/bar', '/baz']) + value = values.Path(choices=['/foo', '/bar', '/baz']) self.assertEqual('/foo', value.deserialize('/foo')) self.assertRaises(ValueError, value.deserialize, '/foobar') def test_deserialize_enforces_required(self): - value = config.Path() + value = values.Path() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = config.Path(optional=True) + value = values.Path(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) @mock.patch('mopidy.utils.path.expand_path') def test_serialize_uses_original(self, expand_path_mock): expand_path_mock.return_value = 'expanded_path' - path = config.ExpandedPath('original_path') - value = config.Path() + path = values.ExpandedPath('original_path') + value = values.Path() self.assertEqual('expanded_path', path) self.assertEqual('original_path', value.serialize(path)) def test_serialize_plain_string(self): - value = config.Path() + value = values.Path() self.assertEqual('path', value.serialize('path')) - - -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_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']) - - -class ExtensionConfigSchemaTest(unittest.TestCase): - def test_schema_includes_enabled(self): - schema = config.ExtensionConfigSchema() - self.assertIsInstance(schema['enabled'], config.Boolean) - - -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) - - -class DidYouMeanTest(unittest.TestCase): - def testSuggestoins(self): - choices = ('enabled', 'username', 'password', 'bitrate', 'timeout') - - suggestion = config.did_you_mean('bitrate', choices) - self.assertEqual(suggestion, 'bitrate') - - suggestion = config.did_you_mean('bitrote', choices) - self.assertEqual(suggestion, 'bitrate') - - suggestion = config.did_you_mean('Bitrot', choices) - self.assertEqual(suggestion, 'bitrate') - - suggestion = config.did_you_mean('BTROT', choices) - self.assertEqual(suggestion, 'bitrate') - - suggestion = config.did_you_mean('btro', choices) - self.assertEqual(suggestion, None) diff --git a/tests/ext_test.py b/tests/ext_test.py index b58333c2..04f52866 100644 --- a/tests/ext_test.py +++ b/tests/ext_test.py @@ -1,14 +1,13 @@ from __future__ import unicode_literals -from mopidy.ext import Extension -from mopidy.utils import config +from mopidy import config, ext from tests import unittest class ExtensionTest(unittest.TestCase): def setUp(self): - self.ext = Extension() + self.ext = ext.Extension() def test_dist_name_is_none(self): self.assertIsNone(self.ext.dist_name) From a5f2dc924cd189441e90ec8fe94dac80fdcc327f Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 11 Apr 2013 22:26:22 +0200 Subject: [PATCH 04/16] config: Review fixes --- mopidy/config/schemas.py | 3 ++- tests/config/validator_tests.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 15d0bfe3..5c90c474 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -1,5 +1,6 @@ -from mopidy import exceptions +from __future__ import unicode_literals +from mopidy import exceptions from mopidy.config import values diff --git a/tests/config/validator_tests.py b/tests/config/validator_tests.py index 4c0ff70f..3993168d 100644 --- a/tests/config/validator_tests.py +++ b/tests/config/validator_tests.py @@ -5,7 +5,6 @@ from mopidy.config import validators from tests import unittest - class ValidateChoiceTest(unittest.TestCase): def test_no_choices_passes(self): validators.validate_choice('foo', None) From 467c8b34dc6fa0f52ed04ac9bb97e029200eddbc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 11 Apr 2013 23:18:17 +0200 Subject: [PATCH 05/16] config: Move load, validate and overrides code into mopidy.config --- mopidy/__main__.py | 87 ++++--------------------------------- mopidy/config/__init__.py | 90 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 79 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 1bfcfbcf..30fcd7f9 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,12 +1,9 @@ from __future__ import unicode_literals -import codecs -import ConfigParser as configparser import logging import optparse import os import signal -import StringIO import sys import gobject @@ -29,12 +26,10 @@ sys.path.insert( from mopidy import exceptions, ext from mopidy.audio import Audio -from mopidy.config import default_config, config_schemas -from mopidy import config as config_utils # TODO: cleanup +from mopidy import config as config_lib from mopidy.core import Core from mopidy.utils import deps, log, path, process, versioning - logger = logging.getLogger('mopidy.main') @@ -51,16 +46,19 @@ def main(): try: create_file_structures() - logging_config = load_config(config_files, config_overrides) + logging_config = config_lib.load(config_files, config_overrides) log.setup_logging( logging_config, options.verbosity_level, options.save_debug_log) extensions = ext.load_extensions() - raw_config = load_config(config_files, config_overrides, extensions) + raw_config = config_lib.load(config_files, config_overrides, extensions) extensions = ext.filter_enabled_extensions(raw_config, extensions) - config = validate_config(raw_config, config_schemas, extensions) + config = config_lib.validate( + raw_config, config_lib.config_schemas, extensions) log.setup_log_levels(config) check_old_locations() + # TODO: wrap config in RO proxy. + # Anything that wants to exit after this point must use # mopidy.utils.process.exit_process as actors have been started. audio = setup_audio(config) @@ -83,9 +81,7 @@ def main(): def check_config_override(option, opt, override): try: - section, remainder = override.split('/', 1) - key, value = remainder.split('=', 1) - return (section, key, value) + return config_lib.parse_override(override) except ValueError: raise optparse.OptionValueError( 'option %s: must have the format section/key=value' % opt) @@ -181,73 +177,6 @@ def check_old_locations(): 'for further instructions.', old_settings_file) -def load_config(files, overrides, extensions=None): - parser = configparser.RawConfigParser() - - files = [path.expand_path(f) for f in files] - sources = ['builtin-defaults'] + files + ['command-line'] - logger.info('Loading config from: %s', ', '.join(sources)) - - # Read default core config - parser.readfp(StringIO.StringIO(default_config)) - - # Read default extension config - for extension in extensions or []: - parser.readfp(StringIO.StringIO(extension.get_default_config())) - - # Load config from a series of config files - for filename in files: - # TODO: if this is the initial load of logging config we might not have - # a logger at this point, we might want to handle this better. - try: - filehandle = codecs.open(filename, encoding='utf-8') - parser.readfp(filehandle) - except IOError: - logger.debug('Config file %s not found; skipping', filename) - continue - except UnicodeDecodeError: - logger.error('Config file %s is not UTF-8 encoded', filename) - sys.exit(1) - - raw_config = {} - for section in parser.sections(): - raw_config[section] = dict(parser.items(section)) - - for section, key, value in overrides or []: - raw_config.setdefault(section, {})[key] = value - - return raw_config - - -def validate_config(raw_config, schemas, extensions=None): - # Collect config schemas to validate against - sections_and_schemas = schemas.items() - for extension in extensions or []: - sections_and_schemas.append( - (extension.ext_name, extension.get_config_schema())) - - # Get validated config - config = {} - errors = {} - for section_name, schema in sections_and_schemas: - if section_name not in raw_config: - errors[section_name] = {section_name: 'section not found'} - try: - items = raw_config[section_name].items() - config[section_name] = schema.convert(items) - except exceptions.ConfigError as error: - errors[section_name] = error - - if errors: - for section_name, error in errors.items(): - logger.error('[%s] config errors:', section_name) - for key in error: - logger.error('%s %s', key, error[key]) - sys.exit(1) - - return config - - def create_file_structures(): path.get_or_create_dir('$XDG_DATA_DIR/mopidy') path.get_or_create_file('$XDG_CONFIG_DIR/mopidy/mopidy.conf') diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 82fc839e..5ac492d4 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -1,7 +1,16 @@ from __future__ import unicode_literals +import codecs +import ConfigParser as configparser +import io +import logging +import sys + from mopidy.config.schemas import * from mopidy.config.values import * +from mopidy.utils import path + +logger = logging.getLogger('mopdiy.config') default_config = """ @@ -44,3 +53,84 @@ config_schemas['proxy']['password'] = String(optional=True, secret=True) # NOTE: if multiple outputs ever comes something like LogLevelConfigSchema #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() + + +# TODO: update API to load(files, defaults, overrides) this should not need to +# know about extensions +def load(files, overrides, extensions=None): + parser = configparser.RawConfigParser() + + files = [path.expand_path(f) for f in files] + sources = ['builtin-defaults'] + files + ['command-line'] + logger.info('Loading config from: %s', ', '.join(sources)) + + # Read default core config + parser.readfp(io.StringIO(default_config)) + + # Read default extension config + for extension in extensions or []: + parser.readfp(io.StringIO(extension.get_default_config())) + + # Load config from a series of config files + for filename in files: + # TODO: if this is the initial load of logging config we might not have + # a logger at this point, we might want to handle this better. + try: + filehandle = codecs.open(filename, encoding='utf-8') + parser.readfp(filehandle) + except IOError: + logger.debug('Config file %s not found; skipping', filename) + continue + except UnicodeDecodeError: + logger.error('Config file %s is not UTF-8 encoded', filename) + sys.exit(1) + + raw_config = {} + for section in parser.sections(): + raw_config[section] = dict(parser.items(section)) + + # TODO: move out of file loading code? + for section, key, value in overrides or []: + raw_config.setdefault(section, {})[key] = value + + return raw_config + + +# TODO: switch API to validate(raw_config, schemas) this should not need to +# know about extensions +def validate(raw_config, schemas, extensions=None): + # Collect config schemas to validate against + sections_and_schemas = schemas.items() + for extension in extensions or []: + sections_and_schemas.append( + (extension.ext_name, extension.get_config_schema())) + + # Get validated config + config = {} + errors = {} + for section_name, schema in sections_and_schemas: + if section_name not in raw_config: + errors[section_name] = {section_name: 'section not found'} + try: + items = raw_config[section_name].items() + config[section_name] = schema.convert(items) + except exceptions.ConfigError as error: + errors[section_name] = error + + if errors: + # TODO: raise error instead. + #raise exceptions.ConfigError(errors) + for section_name, error in errors.items(): + logger.error('[%s] config errors:', section_name) + for key in error: + logger.error('%s %s', key, error[key]) + sys.exit(1) + + return config + + +def parse_override(override): + """Parse section/key=value override.""" + section, remainder = override.split('/', 1) + key, value = remainder.split('=', 1) + return (section, key, value) From 66be2dc551371f45da4561cdd8bfac15895bfbc8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 11 Apr 2013 23:30:29 +0200 Subject: [PATCH 06/16] config: Rename mopidy.config.values to types --- mopidy/config/__init__.py | 2 +- mopidy/config/schemas.py | 6 +- mopidy/config/{values.py => types.py} | 0 .../config/{values_test.py => types_test.py} | 94 +++++++++---------- 4 files changed, 51 insertions(+), 51 deletions(-) rename mopidy/config/{values.py => types.py} (100%) rename tests/config/{values_test.py => types_test.py} (83%) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 5ac492d4..715027cc 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -7,7 +7,7 @@ import logging import sys from mopidy.config.schemas import * -from mopidy.config.values import * +from mopidy.config.types import * from mopidy.utils import path logger = logging.getLogger('mopdiy.config') diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 5c90c474..13928054 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from mopidy import exceptions -from mopidy.config import values +from mopidy.config import types def _did_you_mean(name, choices): @@ -101,7 +101,7 @@ class ExtensionConfigSchema(ConfigSchema): """ def __init__(self): super(ExtensionConfigSchema, self).__init__() - self['enabled'] = values.Boolean() + self['enabled'] = types.Boolean() class LogLevelConfigSchema(object): @@ -112,7 +112,7 @@ class LogLevelConfigSchema(object): :class:`ConfigSchema`, but implements the same interface. """ def __init__(self): - self._config_value = values.LogLevel() + self._config_value = types.LogLevel() def format(self, name, values): lines = ['[%s]' % name] diff --git a/mopidy/config/values.py b/mopidy/config/types.py similarity index 100% rename from mopidy/config/values.py rename to mopidy/config/types.py diff --git a/tests/config/values_test.py b/tests/config/types_test.py similarity index 83% rename from tests/config/values_test.py rename to tests/config/types_test.py index 442fffbb..89fb3ac1 100644 --- a/tests/config/values_test.py +++ b/tests/config/types_test.py @@ -5,14 +5,14 @@ import mock import socket from mopidy import exceptions -from mopidy.config import values +from mopidy.config import types from tests import unittest class ConfigValueTest(unittest.TestCase): def test_init(self): - value = values.ConfigValue() + value = types.ConfigValue() self.assertIsNone(value.choices) self.assertIsNone(value.maximum) self.assertIsNone(value.minimum) @@ -22,7 +22,7 @@ class ConfigValueTest(unittest.TestCase): def test_init_with_params(self): kwargs = {'choices': ['foo'], 'minimum': 0, 'maximum': 10, 'secret': True, 'optional': True} - value = values.ConfigValue(**kwargs) + value = types.ConfigValue(**kwargs) self.assertEqual(['foo'], value.choices) self.assertEqual(0, value.minimum) self.assertEqual(10, value.maximum) @@ -30,90 +30,90 @@ class ConfigValueTest(unittest.TestCase): self.assertEqual(True, value.secret) def test_deserialize_passes_through(self): - value = values.ConfigValue() + value = types.ConfigValue() obj = object() self.assertEqual(obj, value.deserialize(obj)) def test_serialize_conversion_to_string(self): - value = values.ConfigValue() + value = types.ConfigValue() self.assertIsInstance(value.serialize(object()), basestring) def test_format_uses_serialize(self): - value = values.ConfigValue() + value = types.ConfigValue() obj = object() self.assertEqual(value.serialize(obj), value.format(obj)) def test_format_masks_secrets(self): - value = values.ConfigValue(secret=True) + value = types.ConfigValue(secret=True) self.assertEqual('********', value.format(object())) class StringTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = values.String() + value = types.String() self.assertEqual('foo', value.deserialize(' foo ')) def test_deserialize_enforces_choices(self): - value = values.String(choices=['foo', 'bar', 'baz']) + value = types.String(choices=['foo', 'bar', 'baz']) self.assertEqual('foo', value.deserialize('foo')) self.assertRaises(ValueError, value.deserialize, 'foobar') def test_deserialize_enforces_required(self): - value = values.String() + value = types.String() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = values.String(optional=True) + value = types.String(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) def test_serialize_string_escapes(self): - value = values.String() + value = types.String() self.assertEqual(r'\r\n\t', value.serialize('\r\n\t')) def test_format_masks_secrets(self): - value = values.String(secret=True) + value = types.String(secret=True) self.assertEqual('********', value.format('s3cret')) class IntegerTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = values.Integer() + value = types.Integer() self.assertEqual(123, value.deserialize('123')) self.assertEqual(0, value.deserialize('0')) self.assertEqual(-10, value.deserialize('-10')) def test_deserialize_conversion_failure(self): - value = values.Integer() + value = types.Integer() self.assertRaises(ValueError, value.deserialize, 'asd') self.assertRaises(ValueError, value.deserialize, '3.14') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_enforces_choices(self): - value = values.Integer(choices=[1, 2, 3]) + value = types.Integer(choices=[1, 2, 3]) self.assertEqual(3, value.deserialize('3')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_minimum(self): - value = values.Integer(minimum=10) + value = types.Integer(minimum=10) self.assertEqual(15, value.deserialize('15')) self.assertRaises(ValueError, value.deserialize, '5') def test_deserialize_enforces_maximum(self): - value = values.Integer(maximum=10) + value = types.Integer(maximum=10) self.assertEqual(5, value.deserialize('5')) self.assertRaises(ValueError, value.deserialize, '15') def test_format_masks_secrets(self): - value = values.Integer(secret=True) + value = types.Integer(secret=True) self.assertEqual('********', value.format('1337')) class BooleanTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = values.Boolean() + value = types.Boolean() for true in ('1', 'yes', 'true', 'on'): self.assertIs(value.deserialize(true), True) self.assertIs(value.deserialize(true.upper()), True) @@ -124,24 +124,24 @@ class BooleanTest(unittest.TestCase): self.assertIs(value.deserialize(false.capitalize()), False) def test_deserialize_conversion_failure(self): - value = values.Boolean() + value = types.Boolean() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') def test_serialize(self): - value = values.Boolean() + value = types.Boolean() self.assertEqual('true', value.serialize(True)) self.assertEqual('false', value.serialize(False)) def test_format_masks_secrets(self): - value = values.Boolean(secret=True) + value = types.Boolean(secret=True) self.assertEqual('********', value.format('true')) class ListTest(unittest.TestCase): def test_deserialize_conversion_success(self): - value = values.List() + value = types.List() expected = ('foo', 'bar', 'baz') self.assertEqual(expected, value.deserialize('foo, bar ,baz ')) @@ -150,17 +150,17 @@ class ListTest(unittest.TestCase): self.assertEqual(expected, value.deserialize(' foo,bar\nbar\nbaz')) def test_deserialize_enforces_required(self): - value = values.List() + value = types.List() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = values.List(optional=True) + value = types.List(optional=True) self.assertEqual(tuple(), value.deserialize('')) self.assertEqual(tuple(), value.deserialize(' ')) def test_serialize(self): - value = values.List() + value = types.List() result = value.serialize(('foo', 'bar', 'baz')) self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') @@ -173,21 +173,21 @@ class BooleanTest(unittest.TestCase): 'debug': logging.DEBUG} def test_deserialize_conversion_success(self): - value = values.LogLevel() + value = types.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_conversion_failure(self): - value = values.LogLevel() + value = types.LogLevel() self.assertRaises(ValueError, value.deserialize, 'nope') self.assertRaises(ValueError, value.deserialize, 'sure') self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_serialize(self): - value = values.LogLevel() + value = types.LogLevel() for name, level in self.levels.items(): self.assertEqual(name, value.serialize(level)) self.assertIsNone(value.serialize(1337)) @@ -196,26 +196,26 @@ class BooleanTest(unittest.TestCase): class HostnameTest(unittest.TestCase): @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_success(self, getaddrinfo_mock): - value = values.Hostname() + value = types.Hostname() value.deserialize('example.com') getaddrinfo_mock.assert_called_once_with('example.com', None) @mock.patch('socket.getaddrinfo') def test_deserialize_conversion_failure(self, getaddrinfo_mock): - value = values.Hostname() + value = types.Hostname() getaddrinfo_mock.side_effect = socket.error self.assertRaises(ValueError, value.deserialize, 'example.com') @mock.patch('socket.getaddrinfo') def test_deserialize_enforces_required(self, getaddrinfo_mock): - value = values.Hostname() + value = types.Hostname() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') self.assertEqual(0, getaddrinfo_mock.call_count) @mock.patch('socket.getaddrinfo') def test_deserialize_respects_optional(self, getaddrinfo_mock): - value = values.Hostname(optional=True) + value = types.Hostname(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) self.assertEqual(0, getaddrinfo_mock.call_count) @@ -223,14 +223,14 @@ class HostnameTest(unittest.TestCase): class PortTest(unittest.TestCase): def test_valid_ports(self): - value = values.Port() + value = types.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 = values.Port() + value = types.Port() self.assertRaises(ValueError, value.deserialize, '65536') self.assertRaises(ValueError, value.deserialize, '100000') self.assertRaises(ValueError, value.deserialize, '0') @@ -240,48 +240,48 @@ class PortTest(unittest.TestCase): class ExpandedPathTest(unittest.TestCase): def test_is_bytes(self): - self.assertIsInstance(values.ExpandedPath('/tmp'), bytes) + self.assertIsInstance(types.ExpandedPath('/tmp'), bytes) @mock.patch('mopidy.utils.path.expand_path') def test_defaults_to_expanded(self, expand_path_mock): expand_path_mock.return_value = 'expanded_path' - self.assertEqual('expanded_path', values.ExpandedPath('~')) + self.assertEqual('expanded_path', types.ExpandedPath('~')) @mock.patch('mopidy.utils.path.expand_path') def test_orginal_stores_unexpanded(self, expand_path_mock): - self.assertEqual('~', values.ExpandedPath('~').original) + self.assertEqual('~', types.ExpandedPath('~').original) class PathTest(unittest.TestCase): def test_deserialize_conversion_success(self): - result = values.Path().deserialize('/foo') + result = types.Path().deserialize('/foo') self.assertEqual('/foo', result) - self.assertIsInstance(result, values.ExpandedPath) + self.assertIsInstance(result, types.ExpandedPath) self.assertIsInstance(result, bytes) def test_deserialize_enforces_choices(self): - value = values.Path(choices=['/foo', '/bar', '/baz']) + value = types.Path(choices=['/foo', '/bar', '/baz']) self.assertEqual('/foo', value.deserialize('/foo')) self.assertRaises(ValueError, value.deserialize, '/foobar') def test_deserialize_enforces_required(self): - value = values.Path() + value = types.Path() self.assertRaises(ValueError, value.deserialize, '') self.assertRaises(ValueError, value.deserialize, ' ') def test_deserialize_respects_optional(self): - value = values.Path(optional=True) + value = types.Path(optional=True) self.assertIsNone(value.deserialize('')) self.assertIsNone(value.deserialize(' ')) @mock.patch('mopidy.utils.path.expand_path') def test_serialize_uses_original(self, expand_path_mock): expand_path_mock.return_value = 'expanded_path' - path = values.ExpandedPath('original_path') - value = values.Path() + path = types.ExpandedPath('original_path') + value = types.Path() self.assertEqual('expanded_path', path) self.assertEqual('original_path', value.serialize(path)) def test_serialize_plain_string(self): - value = values.Path() + value = types.Path() self.assertEqual('path', value.serialize('path')) From 02518b17df20151a47dda077e3c9446f96403378 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 00:38:22 +0200 Subject: [PATCH 07/16] config: Refactor to internal API that is closer to end goal for load and validate --- mopidy/config/__init__.py | 46 +++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 715027cc..a4e7a364 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -55,29 +55,31 @@ config_schemas['proxy']['password'] = String(optional=True, secret=True) #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() -# TODO: update API to load(files, defaults, overrides) this should not need to -# know about extensions def load(files, overrides, extensions=None): + defaults = [default_config] + if extensions: + defaults.extend(e.get_default_config() for e in extensions) + return _load(files, defaults, extensions) + + +# TODO: replace load() with this version of API. +def _load(files, defaults, overrides): parser = configparser.RawConfigParser() files = [path.expand_path(f) for f in files] sources = ['builtin-defaults'] + files + ['command-line'] logger.info('Loading config from: %s', ', '.join(sources)) - # Read default core config - parser.readfp(io.StringIO(default_config)) - - # Read default extension config - for extension in extensions or []: - parser.readfp(io.StringIO(extension.get_default_config())) + for default in defaults: + parser.readfp(io.StringIO(default)) # Load config from a series of config files for filename in files: # TODO: if this is the initial load of logging config we might not have # a logger at this point, we might want to handle this better. try: - filehandle = codecs.open(filename, encoding='utf-8') - parser.readfp(filehandle) + with codecs.open(filename, encoding='utf-8') as filehandle: + parser.readfp(filehandle) except IOError: logger.debug('Config file %s not found; skipping', filename) continue @@ -89,39 +91,41 @@ def load(files, overrides, extensions=None): for section in parser.sections(): raw_config[section] = dict(parser.items(section)) - # TODO: move out of file loading code? for section, key, value in overrides or []: raw_config.setdefault(section, {})[key] = value return raw_config -# TODO: switch API to validate(raw_config, schemas) this should not need to -# know about extensions def validate(raw_config, schemas, extensions=None): # Collect config schemas to validate against sections_and_schemas = schemas.items() for extension in extensions or []: sections_and_schemas.append( (extension.ext_name, extension.get_config_schema())) + return _validate(raw_config, sections_and_schemas) + +# TODO: replace validate() with this version of API. +def _validate(raw_config, schemas): # Get validated config config = {} errors = {} - for section_name, schema in sections_and_schemas: - if section_name not in raw_config: - errors[section_name] = {section_name: 'section not found'} + for name, schema in schemas: + if name not in raw_config: + errors[name] = {name: 'section not found'} try: - items = raw_config[section_name].items() - config[section_name] = schema.convert(items) + items = raw_config[name].items() + config[name] = schema.convert(items) + # TODO: convert to ConfigSchemaError except exceptions.ConfigError as error: - errors[section_name] = error + errors[name] = error if errors: # TODO: raise error instead. #raise exceptions.ConfigError(errors) - for section_name, error in errors.items(): - logger.error('[%s] config errors:', section_name) + for name, error in errors.items(): + logger.error('[%s] config errors:', name) for key in error: logger.error('%s %s', key, error[key]) sys.exit(1) From 51afbe19e12ea338a1034999863a83ff872699f5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 00:39:00 +0200 Subject: [PATCH 08/16] config: Start adding basic data loading test --- tests/config/config_test.py | 49 +++++++++++++++++++++++++++++++++++++ tests/data/file1.conf | 2 ++ tests/data/file2.conf | 2 ++ 3 files changed, 53 insertions(+) create mode 100644 tests/config/config_test.py create mode 100644 tests/data/file1.conf create mode 100644 tests/data/file2.conf diff --git a/tests/config/config_test.py b/tests/config/config_test.py new file mode 100644 index 00000000..bb161a8f --- /dev/null +++ b/tests/config/config_test.py @@ -0,0 +1,49 @@ +from __future__ import unicode_literals + +from mopidy import config + +from tests import unittest, path_to_data_dir + + +class LoadConfigTest(unittest.TestCase): + def test_load_nothing(self): + self.assertEqual({}, config._load([], [], [])) + + def test_load_single_default(self): + default = '[foo]\nbar = baz' + expected = {'foo': {'bar': 'baz'}} + result = config._load([], [default], []) + self.assertEqual(expected, result) + + def test_load_defaults(self): + default1 = '[foo]\nbar = baz' + default2 = '[foo2]\n' + expected = {'foo': {'bar': 'baz'}, 'foo2': {}} + result = config._load([], [default1, default2], []) + self.assertEqual(expected, result) + + def test_load_single_override(self): + override = ('foo', 'bar', 'baz') + expected = {'foo': {'bar': 'baz'}} + result = config._load([], [], [override]) + self.assertEqual(expected, result) + + def test_load_overrides(self): + override1 = ('foo', 'bar', 'baz') + override2 = ('foo2', 'bar', 'baz') + expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}} + result = config._load([], [], [override1, override2]) + self.assertEqual(expected, result) + + def test_load_single_file(self): + file1 = path_to_data_dir('file1.conf') + expected = {'foo': {'bar': 'baz'}} + result = config._load([file1], [], []) + self.assertEqual(expected, result) + + def test_load_files(self): + file1 = path_to_data_dir('file1.conf') + file2 = path_to_data_dir('file2.conf') + expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}} + result = config._load([file1, file2], [], []) + self.assertEqual(expected, result) diff --git a/tests/data/file1.conf b/tests/data/file1.conf new file mode 100644 index 00000000..e6396bff --- /dev/null +++ b/tests/data/file1.conf @@ -0,0 +1,2 @@ +[foo] +bar = baz diff --git a/tests/data/file2.conf b/tests/data/file2.conf new file mode 100644 index 00000000..ef189703 --- /dev/null +++ b/tests/data/file2.conf @@ -0,0 +1,2 @@ +[foo2] +bar = baz From c5f8e1da19cabd725d314af6285360061173007d Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 00:53:19 +0200 Subject: [PATCH 09/16] config: Add parse_override test --- mopidy/config/__init__.py | 2 +- tests/config/config_test.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index a4e7a364..935f8743 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -137,4 +137,4 @@ def parse_override(override): """Parse section/key=value override.""" section, remainder = override.split('/', 1) key, value = remainder.split('=', 1) - return (section, key, value) + return (section.strip(), key.strip(), value.strip()) diff --git a/tests/config/config_test.py b/tests/config/config_test.py index bb161a8f..bb24e3a2 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -47,3 +47,22 @@ class LoadConfigTest(unittest.TestCase): expected = {'foo': {'bar': 'baz'}, 'foo2': {'bar': 'baz'}} result = config._load([file1, file2], [], []) self.assertEqual(expected, result) + + +class ParseOverrideTest(unittest.TestCase): + def test_valid_override(self): + expected = ('section', 'key', 'value') + self.assertEqual(expected, config.parse_override('section/key=value')) + self.assertEqual(expected, config.parse_override('section/key=value ')) + self.assertEqual(expected, config.parse_override('section/key =value')) + self.assertEqual(expected, config.parse_override('section /key=value')) + + def test_empty_override(self): + expected = ('section', 'key', '') + self.assertEqual(expected, config.parse_override('section/key=')) + self.assertEqual(expected, config.parse_override('section/key= ')) + + def test_invalid_override(self): + self.assertRaises(ValueError, config.parse_override, 'section/key') + self.assertRaises(ValueError, config.parse_override, 'section=') + self.assertRaises(ValueError, config.parse_override, 'section') From 067cc4c112d02dd3085aa2ae99406adca9b803ce Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 01:18:26 +0200 Subject: [PATCH 10/16] config: Add basic validate tests --- mopidy/config/__init__.py | 35 +++++++++++++++++--------------- tests/config/config_test.py | 40 ++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 935f8743..38db4c42 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -103,23 +103,8 @@ def validate(raw_config, schemas, extensions=None): for extension in extensions or []: sections_and_schemas.append( (extension.ext_name, extension.get_config_schema())) - return _validate(raw_config, sections_and_schemas) - -# TODO: replace validate() with this version of API. -def _validate(raw_config, schemas): - # Get validated config - config = {} - errors = {} - for name, schema in schemas: - if name not in raw_config: - errors[name] = {name: 'section not found'} - try: - items = raw_config[name].items() - config[name] = schema.convert(items) - # TODO: convert to ConfigSchemaError - except exceptions.ConfigError as error: - errors[name] = error + config, errors = _validate(raw_config, sections_and_schemas) if errors: # TODO: raise error instead. @@ -133,6 +118,24 @@ def _validate(raw_config, schemas): return config +# TODO: replace validate() with this version of API. +def _validate(raw_config, schemas): + # Get validated config + config = {} + errors = [] + for name, schema in schemas: + try: + items = raw_config[name].items() + config[name] = schema.convert(items) + except KeyError: + errors.append('%s: section not found.' % name) + except exceptions.ConfigError as error: + for key in error: + errors.append('%s/%s: %s' % (name, key, error[key])) + # TODO: raise errors instead of return + return config, errors + + def parse_override(override): """Parse section/key=value override.""" section, remainder = override.split('/', 1) diff --git a/tests/config/config_test.py b/tests/config/config_test.py index bb24e3a2..00cb4e83 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals -from mopidy import config +import mock + +from mopidy import config, exceptions from tests import unittest, path_to_data_dir @@ -49,6 +51,42 @@ class LoadConfigTest(unittest.TestCase): self.assertEqual(expected, result) +class ValidateTest(unittest.TestCase): + def test_empty_config_no_schemas(self): + conf, errors = config._validate({}, []) + self.assertEqual({}, conf) + self.assertEqual([], errors) + + def test_config_no_schemas(self): + raw_config = {'foo': {'bar': 'baz'}} + conf, errors = config._validate(raw_config, []) + self.assertEqual({}, conf) + self.assertEqual([], errors) + + def test_empty_config_single_schema(self): + conf, errors = config._validate({}, [('foo', mock.Mock())]) + self.assertEqual({}, conf) + self.assertEqual(['foo: section not found.'], errors) + + def test_config_single_schema(self): + raw_config = {'foo': {'bar': 'baz'}} + schema = mock.Mock() + schema.convert.return_value = {'baz': 'bar'} + conf, errors = config._validate(raw_config, [('foo', schema)]) + self.assertEqual({'foo': {'baz': 'bar'}}, conf) + self.assertEqual([], errors) + + def test_config_single_schema_config_error(self): + raw_config = {'foo': {'bar': 'baz'}} + schema = mock.Mock() + schema.convert.side_effect = exceptions.ConfigError({'bar': 'bad'}) + conf, errors = config._validate(raw_config, [('foo', schema)]) + self.assertEqual(['foo/bar: bad'], errors) + self.assertEqual({}, conf) + + # TODO: add more tests + + class ParseOverrideTest(unittest.TestCase): def test_valid_override(self): expected = ('section', 'key', 'value') From e98ca4c94caebb6b46611f7f6c5d38f2bc30f096 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 01:35:55 +0200 Subject: [PATCH 11/16] config: Handle encoding and other minor refactoring mistakes --- mopidy/config/__init__.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 38db4c42..cca217c3 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -59,7 +59,7 @@ def load(files, overrides, extensions=None): defaults = [default_config] if extensions: defaults.extend(e.get_default_config() for e in extensions) - return _load(files, defaults, extensions) + return _load(files, defaults, overrides) # TODO: replace load() with this version of API. @@ -70,8 +70,8 @@ def _load(files, defaults, overrides): sources = ['builtin-defaults'] + files + ['command-line'] logger.info('Loading config from: %s', ', '.join(sources)) - for default in defaults: - parser.readfp(io.StringIO(default)) + for default in defaults: # TODO: remove decoding + parser.readfp(io.StringIO(default.decode('utf-8'))) # Load config from a series of config files for filename in files: @@ -109,10 +109,8 @@ def validate(raw_config, schemas, extensions=None): if errors: # TODO: raise error instead. #raise exceptions.ConfigError(errors) - for name, error in errors.items(): - logger.error('[%s] config errors:', name) - for key in error: - logger.error('%s %s', key, error[key]) + for error in errors: + logger.error(error) sys.exit(1) return config From 63003abb2ecbadcb5ae2ec86414b190dd5ab3783 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 20:40:13 +0200 Subject: [PATCH 12/16] config: Flake8 fixes --- mopidy/__main__.py | 10 ++++++---- mopidy/config/__init__.py | 3 +-- mopidy/config/schemas.py | 4 +--- mopidy/config/types.py | 2 +- mopidy/config/validators.py | 1 + tests/config/schemas_test.py | 7 ++++--- tests/config/types_test.py | 3 +-- tests/config/validator_tests.py | 8 +++++--- 8 files changed, 20 insertions(+), 18 deletions(-) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 30fcd7f9..a9649bd1 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -24,7 +24,7 @@ sys.path.insert( 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import exceptions, ext +from mopidy import ext from mopidy.audio import Audio from mopidy import config as config_lib from mopidy.core import Core @@ -136,12 +136,14 @@ def show_config_callback(option, opt, value, parser): overrides = getattr(parser.values, 'overrides', []) extensions = ext.load_extensions() - raw_config = load_config(files, overrides, extensions) + raw_config = config_lib.load(files, overrides, extensions) enabled_extensions = ext.filter_enabled_extensions(raw_config, extensions) - config = validate_config(raw_config, config_schemas, enabled_extensions) + config = config_lib.validate( + raw_config, config_lib.config_schemas, enabled_extensions) + # TODO: create mopidy.config.format? output = [] - for section_name, schema in config_schemas.items(): + for section_name, schema in config_lib.config_schemas.items(): options = config.get(section_name, {}) if not options: continue diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index cca217c3..6cf352f5 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -69,8 +69,7 @@ def _load(files, defaults, overrides): files = [path.expand_path(f) for f in files] sources = ['builtin-defaults'] + files + ['command-line'] logger.info('Loading config from: %s', ', '.join(sources)) - - for default in defaults: # TODO: remove decoding + for default in defaults: # TODO: remove decoding parser.readfp(io.StringIO(default.decode('utf-8'))) # Load config from a series of config files diff --git a/mopidy/config/schemas.py b/mopidy/config/schemas.py index 13928054..b074e79a 100644 --- a/mopidy/config/schemas.py +++ b/mopidy/config/schemas.py @@ -36,13 +36,11 @@ def _levenshtein(a, b): return current[n] - - class ConfigSchema(object): """Logical group of config values that correspond to a config section. Schemas are set up by assigning config keys with config values to - instances. Once setup :meth:`convert` can be called with a list of + 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. """ diff --git a/mopidy/config/types.py b/mopidy/config/types.py index 0fe40961..43878e87 100644 --- a/mopidy/config/types.py +++ b/mopidy/config/types.py @@ -194,7 +194,7 @@ class ExpandedPath(bytes): class Path(ConfigValue): - """File system path that will be expanded with mopidy.utils.path.expand_path + """File system path that will be expanded. Supports: optional, choices and secret. """ diff --git a/mopidy/config/validators.py b/mopidy/config/validators.py index ab7282be..0fda118d 100644 --- a/mopidy/config/validators.py +++ b/mopidy/config/validators.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals # TODO: add validate regexp? + def validate_required(value, required): """Required validation, normally called in config value's validate() on the raw string, _not_ the converted value.""" diff --git a/tests/config/schemas_test.py b/tests/config/schemas_test.py index 4920bbfe..e7d6dde1 100644 --- a/tests/config/schemas_test.py +++ b/tests/config/schemas_test.py @@ -4,7 +4,7 @@ import logging import mock from mopidy import exceptions -from mopidy.config import schemas +from mopidy.config import schemas, types from tests import unittest @@ -89,7 +89,7 @@ class ConfigSchemaTest(unittest.TestCase): class ExtensionConfigSchemaTest(unittest.TestCase): def test_schema_includes_enabled(self): schema = schemas.ExtensionConfigSchema() - self.assertIsInstance(schema['enabled'], values.Boolean) + self.assertIsInstance(schema['enabled'], types.Boolean) class LogLevelConfigSchemaTest(unittest.TestCase): @@ -102,8 +102,9 @@ class LogLevelConfigSchemaTest(unittest.TestCase): def test_format(self): schema = schemas.LogLevelConfigSchema() + values = {'foo.bar': logging.DEBUG, 'baz': logging.INFO} expected = ['[levels]', 'baz = info', 'foo.bar = debug'] - result = schema.format('levels', {'foo.bar': logging.DEBUG, 'baz': logging.INFO}) + result = schema.format('levels', values) self.assertEqual('\n'.join(expected), result) diff --git a/tests/config/types_test.py b/tests/config/types_test.py index 89fb3ac1..448283b1 100644 --- a/tests/config/types_test.py +++ b/tests/config/types_test.py @@ -4,7 +4,6 @@ import logging import mock import socket -from mopidy import exceptions from mopidy.config import types from tests import unittest @@ -165,7 +164,7 @@ class ListTest(unittest.TestCase): self.assertRegexpMatches(result, r'foo\n\s*bar\n\s*baz') -class BooleanTest(unittest.TestCase): +class LogLevelTest(unittest.TestCase): levels = {'critical': logging.CRITICAL, 'error': logging.ERROR, 'warning': logging.WARNING, diff --git a/tests/config/validator_tests.py b/tests/config/validator_tests.py index 3993168d..57489b6b 100644 --- a/tests/config/validator_tests.py +++ b/tests/config/validator_tests.py @@ -14,12 +14,14 @@ class ValidateChoiceTest(unittest.TestCase): validators.validate_choice(1, [1, 2, 3]) def test_empty_choices_fails(self): - self.assertRaises(ValueError,validators.validate_choice, 'foo', []) + self.assertRaises(ValueError, validators.validate_choice, 'foo', []) def test_invalid_value_fails(self): words = ['foo', 'bar', 'baz'] - self.assertRaises(ValueError, validators.validate_choice, 'foobar', words) - self.assertRaises(ValueError, validators.validate_choice, 5, [1, 2, 3]) + self.assertRaises( + ValueError, validators.validate_choice, 'foobar', words) + self.assertRaises( + ValueError, validators.validate_choice, 5, [1, 2, 3]) class ValidateMinimumTest(unittest.TestCase): From 917c1e4c9d88137d649b6a00aef6f16183f662d8 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 20:51:09 +0200 Subject: [PATCH 13/16] config: Use default config file --- mopidy/config/__init__.py | 28 +++++----------------------- mopidy/{ => config}/default.conf | 0 2 files changed, 5 insertions(+), 23 deletions(-) rename mopidy/{ => config}/default.conf (100%) diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index 6cf352f5..b04bcc44 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -4,6 +4,7 @@ import codecs import ConfigParser as configparser import io import logging +import os.path import sys from mopidy.config.schemas import * @@ -12,28 +13,7 @@ from mopidy.utils import path logger = logging.getLogger('mopdiy.config') - -default_config = """ -[logging] -console_format = %(levelname)-8s %(message)s -debug_format = %(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s -debug_file = mopidy.log - -[logging.levels] -pykka = info - -[audio] -mixer = autoaudiomixer -mixer_track = -output = autoaudiosink - -[proxy] -hostname = -username = -password = -""" - -config_schemas = {} # TODO: use ordered dict? +config_schemas = {} # TODO: use ordered dict or list? config_schemas['logging'] = ConfigSchema() config_schemas['logging']['console_format'] = String() config_schemas['logging']['debug_format'] = String() @@ -56,7 +36,9 @@ config_schemas['proxy']['password'] = String(optional=True, secret=True) def load(files, overrides, extensions=None): - defaults = [default_config] + default_config_file = os.path.join( + os.path.dirname(__file__), 'default.conf') + defaults = [open(default_config_file).read()] if extensions: defaults.extend(e.get_default_config() for e in extensions) return _load(files, defaults, overrides) diff --git a/mopidy/default.conf b/mopidy/config/default.conf similarity index 100% rename from mopidy/default.conf rename to mopidy/config/default.conf From b655e846b1767ce737d6838eb333ffbf2b665f0b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 21:06:09 +0200 Subject: [PATCH 14/16] config: Add read helper --- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/spotify/__init__.py | 2 +- mopidy/backends/stream/__init__.py | 2 +- mopidy/config/__init__.py | 11 ++++++++--- mopidy/frontends/http/__init__.py | 2 +- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/frontends/mpris/__init__.py | 2 +- mopidy/frontends/scrobbler/__init__.py | 2 +- 8 files changed, 15 insertions(+), 10 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index a6c96a3a..161506e3 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 2860b593..2833c4c4 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 732fd3f2..3f116eed 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/config/__init__.py b/mopidy/config/__init__.py index b04bcc44..48334942 100644 --- a/mopidy/config/__init__.py +++ b/mopidy/config/__init__.py @@ -35,10 +35,15 @@ config_schemas['proxy']['password'] = String(optional=True, secret=True) #config_schemas['audio.outputs'] = config.AudioOutputConfigSchema() +def read(config_file): + """Helper to load defaults in same way across core and extensions.""" + with io.open(config_file, 'rb') as filehandle: + return filehandle.read() + + def load(files, overrides, extensions=None): - default_config_file = os.path.join( - os.path.dirname(__file__), 'default.conf') - defaults = [open(default_config_file).read()] + config_dir = os.path.dirname(__file__) + defaults = [read(os.path.join(config_dir, 'default.conf'))] if extensions: defaults.extend(e.get_default_config() for e in extensions) return _load(files, defaults, overrides) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 34fa065a..07b9285d 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index f108aec5..5b45a9c1 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index a2a6edf3..fcb9a634 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py index 0aa0bdc6..f4208824 100644 --- a/mopidy/frontends/scrobbler/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -14,7 +14,7 @@ class Extension(ext.Extension): def get_default_config(self): conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') - return open(conf_file).read() + return config.read(conf_file) def get_config_schema(self): schema = config.ExtensionConfigSchema() From d143ec6e369bff65a032be165c464e358b203e3b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 21:07:59 +0200 Subject: [PATCH 15/16] ext: Rename config_utils import to config_lib --- mopidy/ext.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/ext.py b/mopidy/ext.py index 1d554e72..03491f57 100644 --- a/mopidy/ext.py +++ b/mopidy/ext.py @@ -4,7 +4,7 @@ import logging import pkg_resources from mopidy import exceptions -from mopidy import config as config_utils +from mopidy import config as config_lib logger = logging.getLogger('mopidy.ext') @@ -21,7 +21,7 @@ class Extension(object): 'Add at least a config section with "enabled = true"') def get_config_schema(self): - return config_utils.ExtensionConfigSchema() + return config_lib.ExtensionConfigSchema() def validate_environment(self): pass @@ -76,7 +76,7 @@ def load_extensions(): def filter_enabled_extensions(raw_config, extensions): - boolean = config_utils.Boolean() + boolean = config_lib.Boolean() enabled_extensions = [] enabled_names = [] disabled_names = [] From 5fd4c187929a4b70152ee41453b0b5797635f54a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 13 Apr 2013 21:12:48 +0200 Subject: [PATCH 16/16] ext: Get config schema from super --- mopidy/backends/local/__init__.py | 2 +- mopidy/backends/spotify/__init__.py | 2 +- mopidy/backends/stream/__init__.py | 2 +- mopidy/frontends/http/__init__.py | 2 +- mopidy/frontends/mpd/__init__.py | 2 +- mopidy/frontends/mpris/__init__.py | 2 +- mopidy/frontends/scrobbler/__init__.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 161506e3..f718eeb5 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['media_dir'] = config.Path() schema['playlists_dir'] = config.Path() schema['tag_cache_file'] = config.Path() diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 2833c4c4..55d0e3d7 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['username'] = config.String() schema['password'] = config.String(secret=True) schema['bitrate'] = config.Integer(choices=(96, 160, 320)) diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py index 3f116eed..061ac5d0 100644 --- a/mopidy/backends/stream/__init__.py +++ b/mopidy/backends/stream/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['protocols'] = config.List() return schema diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 07b9285d..6d84b25b 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['hostname'] = config.Hostname() schema['port'] = config.Port() schema['static_dir'] = config.Path(optional=True) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 5b45a9c1..04c00c2b 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['hostname'] = config.Hostname() schema['port'] = config.Port() schema['password'] = config.String(optional=True, secret=True) diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index fcb9a634..1fd258b5 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['desktop_file'] = config.Path() return schema diff --git a/mopidy/frontends/scrobbler/__init__.py b/mopidy/frontends/scrobbler/__init__.py index f4208824..dcc6f195 100644 --- a/mopidy/frontends/scrobbler/__init__.py +++ b/mopidy/frontends/scrobbler/__init__.py @@ -17,7 +17,7 @@ class Extension(ext.Extension): return config.read(conf_file) def get_config_schema(self): - schema = config.ExtensionConfigSchema() + schema = super(Extension, self).get_config_schema() schema['username'] = config.String() schema['password'] = config.String(secret=True) return schema