diff --git a/beets/config_default.yaml b/beets/config_default.yaml index bc02bbb5a..01e4e8758 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -17,12 +17,12 @@ import: ignore: [".*", "*~"] replace: - - '[\\/]': _ - - '^\.': _ - - '[\x00-\x1f]': _ - - '[<>:"\?\*\|]': _ - - '\.$': _ - - '\s+$': '' + '[\\/]': _ + '^\.': _ + '[\x00-\x1f]': _ + '[<>:"\?\*\|]': _ + '\.$': _ + '\s+$': '' art_filename: cover plugins: [] @@ -37,6 +37,6 @@ list_format_item: $artist - $album - $title list_format_album: $albumartist - $album paths: - - default: $albumartist/$album%aunique{}/$track $title - - singleton: Non-Album/$artist/$title - - comp: Compilations/$album%aunique{}/$track $title + default: $albumartist/$album%aunique{}/$track $title + singleton: Non-Album/$artist/$title + comp: Compilations/$album%aunique{}/$track $title diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 6f9fd0aed..9c0279d79 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -395,21 +395,19 @@ def get_path_formats(): """Get the configuration's path formats as a list of query/template pairs. """ - pairs = config['paths'].as_pairs(True) path_formats = [] - for query, fmt in pairs: + for query, view in config['paths'].items(): query = PF_KEY_QUERIES.get(query, query) # Expand common queries. - path_formats.append((query, Template(fmt))) + path_formats.append((query, Template(view.get(unicode)))) return path_formats def get_replacements(): """Confit validation function that reads regex/string pairs. """ - pairs = config['replace'].as_pairs() replacements = [] - for pattern, value in pairs: + for pattern, view in config['replace'].items(): try: - replacements.append((re.compile(pattern), value)) + replacements.append((re.compile(pattern), view.get(unicode))) except re.error: raise UserError( u'malformed regular expression in replace: {0}'.format( diff --git a/beets/util/confit.py b/beets/util/confit.py index a5fd40026..1ab06b980 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -21,6 +21,10 @@ import pkgutil import sys import yaml import types +try: + from collections import OrderedDict +except ImportError: + from ordereddict import OrderedDict UNIX_DIR_VAR = 'XDG_CONFIG_HOME' UNIX_DIR_FALLBACK = '~/.config' @@ -30,12 +34,14 @@ MAC_DIR = '~/Library/Application Support' CONFIG_FILENAME = 'config.yaml' DEFAULT_FILENAME = 'config_default.yaml' +ROOT_NAME = 'root' # Utilities. PY3 = sys.version_info[0] == 3 STRING = str if PY3 else unicode +BASESTRING = str if PY3 else basestring NUMERIC_TYPES = (int, float) if PY3 else (int, float, long) TYPE_TYPES = (type,) if PY3 else (type, types.ClassType) @@ -97,11 +103,6 @@ class ConfigView(object): configuration in Python-like syntax (e.g., ``foo['bar'][42]``). """ - overlay = None - """The portion of the transient overlay corresponding to this - view. - """ - def get_all(self): """Generates all available values for the view in the order of the configuration's sources. (Each source may have at most one @@ -138,6 +139,11 @@ class ConfigView(object): return value + def set(self, value): + """Create an overlay source to set the value at this view. + """ + raise NotImplementedError + def __repr__(self): return '' % self.name @@ -146,20 +152,20 @@ class ConfigView(object): return Subview(self, key) def __setitem__(self, key, value): - """Set a value in the transient overlay for a certain key under - this view. + """Create an overlay source to assign a given key under this + view. """ - self.overlay[key] = value + self.set({key: value}) def add_args(self, namespace): - """Add parsed command-line arguments, generated by a library - like argparse or optparse, to this view's overlay. + """Overlay parsed command-line arguments, generated by a library + like argparse or optparse, onto this view's value. """ args = {} for key, value in namespace.__dict__.items(): if value is not None: # Avoid unset options. args[key] = value - self.overlay.update(args) + self.set(args) # Magical conversions. These special methods make it possible to use # View objects somewhat transparently in certain circumstances. For @@ -169,13 +175,13 @@ class ConfigView(object): def __str__(self): """Gets the value for this view as a byte string.""" return str(self.get()) - + def __unicode__(self): """Gets the value for this view as a unicode string. (Python 2 only.) """ return unicode(self.get()) - + def __nonzero__(self): """Gets the value for this view as a boolean. (Python 2 only.) """ @@ -279,30 +285,25 @@ class ConfigView(object): ) ) - def as_pairs(self, all_sources=False): - """Ensure that the value is a list whose elements are either - pairs (two-element lists) or single-entry dictionaries (which - have a slightly nicer syntax in YAML). Return a list of pairs - (tuples). If `all_sources`, then the values from all sources are - concatenated. + def as_str_seq(self): + """Get the value as a list of strings. The underlying configured + value can be a sequence or a single string. In the latter case, + the string is treated as a white-space separated list of words. """ - if all_sources: - it = self.all_contents() - else: - it = self.get(list) + value = self.get() + if isinstance(value, bytes): + value = value.decode('utf8', 'ignore') - out = [] - for item in it: - if isinstance(item, (list, tuple)) and len(item) == 2: - out.append(tuple(item)) - elif isinstance(item, dict) and len(item) == 1: - out.append(iter_first(item.items())) - else: - raise ConfigValueError( - '{0} must be a list of pairs'.format(self.name) + if isinstance(value, STRING): + return value.split() + else: + try: + return list(value) + except TypeError: + raise ConfigTypeError( + '{0} must be a whitespace-separated string or ' + 'a list'.format(self.name) ) - - return out class RootView(ConfigView): """The base of a view hierarchy. This view keeps track of the @@ -314,8 +315,7 @@ class RootView(ConfigView): has the highest priority. """ self.sources = list(sources) - self.overlay = {} - self.name = 'root' + self.name = ROOT_NAME def add(self, obj): """Add the object (probably a dict) as a source for @@ -326,8 +326,11 @@ class RootView(ConfigView): """ self.sources.append(obj) + def set(self, value): + self.sources.insert(0, value) + def get_all(self): - return [self.overlay] + self.sources + return self.sources class Subview(ConfigView): """A subview accessed via a subscript of a parent view.""" @@ -336,7 +339,20 @@ class Subview(ConfigView): """ self.parent = parent self.key = key - self.name = '{0}[{1}]'.format(self.parent.name, repr(self.key)) + + # Choose a human-readable name for this view. + if isinstance(self.parent, RootView): + self.name = '' + else: + self.name = self.parent.name + if not isinstance(self.key, int): + self.name += '.' + if isinstance(self.key, int): + self.name += '#{0}'.format(self.key) + elif isinstance(self.key, BASESTRING): + self.name += '{0}'.format(self.key) + else: + self.name += '{0}'.format(repr(self.key)) def get_all(self): for collection in self.parent.get_all(): @@ -357,12 +373,8 @@ class Subview(ConfigView): ) yield value - @property - def overlay(self): - parent_overlay = self.parent.overlay - if self.key not in parent_overlay: - parent_overlay[self.key] = {} - return parent_overlay[self.key] + def set(self, value): + self.parent.set({self.key: value}) # Config file paths, including platform-specific paths and in-package @@ -415,12 +427,58 @@ def config_dirs(): # YAML. class Loader(yaml.SafeLoader): - """A customized YAML safe loader that reads all strings as Unicode - objects. + """A customized YAML loader. This loader deviates from the official + YAML spec in a few convenient ways: + + - All strings as are Unicode objects. + - All maps are OrderedDicts. + - Strings can begin with % without quotation. """ + # All strings should be Unicode objects, regardless of contents. def _construct_unicode(self, node): return self.construct_scalar(node) + + # Use ordered dictionaries for every YAML map. + # From https://gist.github.com/844388 + def construct_yaml_map(self, node): + data = OrderedDict() + yield data + value = self.construct_mapping(node) + data.update(value) + + def construct_mapping(self, node, deep=False): + if isinstance(node, yaml.MappingNode): + self.flatten_mapping(node) + else: + raise yaml.constructor.ConstructorError( + None, None, + 'expected a mapping node, but found %s' % node.id, + node.start_mark + ) + + mapping = OrderedDict() + for key_node, value_node in node.value: + key = self.construct_object(key_node, deep=deep) + try: + hash(key) + except TypeError as exc: + raise yaml.constructor.ConstructorError( + 'while constructing a mapping', + node.start_mark, 'found unacceptable key (%s)' % exc, + key_node.start_mark + ) + value = self.construct_object(value_node, deep=deep) + mapping[key] = value + return mapping + + # Allow bare strings to begin with %. Directives are still detected. + def check_plain(self): + plain = super(Loader, self).check_plain() + return plain or self.peek() == '%' + Loader.add_constructor('tag:yaml.org,2002:str', Loader._construct_unicode) +Loader.add_constructor('tag:yaml.org,2002:map', Loader.construct_yaml_map) +Loader.add_constructor('tag:yaml.org,2002:omap', Loader.construct_yaml_map) def load_yaml(filename): """Read a YAML document from a file. If the file cannot be read or @@ -436,21 +494,25 @@ def load_yaml(filename): # Main interface. class Configuration(RootView): - def __init__(self, name, modname=None, read=True): + def __init__(self, appname, modname=None, read=True): """Create a configuration object by reading the automatically-discovered config files for the application for a given name. If `modname` is specified, it should be the import name of a module whose package will be searched for a default - config file. (Otherwise, no defaults are used.) + config file. (Otherwise, no defaults are used.) Pass `False` for + `read` to disable automatic reading of all discovered + configuration files. Use this when creating a configuration + object at module load time and then call the `read` method + later. """ super(Configuration, self).__init__([]) - self.name = name + self.appname = appname self.modname = modname - self._env_var = '{0}DIR'.format(self.name.upper()) + self._env_var = '{0}DIR'.format(self.appname.upper()) if read: - self._read() + self.read() def _search_dirs(self): """Yield directories that will be searched for configuration @@ -463,33 +525,39 @@ class Configuration(RootView): # Standard configuration directories. for confdir in config_dirs(): - yield os.path.join(confdir, self.name) + yield os.path.join(confdir, self.appname) - def _filenames(self): + def _filenames(self, user=True, defaults=True): """Get a list of filenames for configuration files. The files - actually exist and are in the order that they should be - prioritized. + must actually exist and are placed in the order that they should + be prioritized. The `user` and `defaults` flags control whether + files should be used from discovered configuration directories + and from the in-package defaults directory; set either of these + to `False` to disable searching for them. """ out = [] # Search standard directories. - for appdir in self._search_dirs(): - out.append(os.path.join(appdir, CONFIG_FILENAME)) + if user: + for appdir in self._search_dirs(): + out.append(os.path.join(appdir, CONFIG_FILENAME)) # Search the package for a defaults file. - if self.modname: + if defaults and self.modname: pkg_path = _package_path(self.modname) if pkg_path: out.append(os.path.join(pkg_path, DEFAULT_FILENAME)) return [p for p in out if os.path.isfile(p)] - def _read(self): - """Read the default files for this configuration and set them as - the sources for this configuration. + def read(self, user=True, defaults=True): + """Find and read the files for this configuration and set them + as the sources for this configuration. To disable either + discovered user configuration files or the in-package defaults, + set `user` or `defaults` to `False`. """ self.sources = [] - for filename in self._filenames(): + for filename in self._filenames(user, defaults): self.sources.append(load_yaml(filename)) def config_dir(self): diff --git a/beetsplug/inline.py b/beetsplug/inline.py index b3d67fc43..2daaf068c 100644 --- a/beetsplug/inline.py +++ b/beetsplug/inline.py @@ -63,8 +63,8 @@ class InlinePlugin(BeetsPlugin): super(InlinePlugin, self).__init__() # Add field expressions. - for key, value in config['pathfields'].as_pairs(): + for key, view in config['pathfields'].items(): log.debug(u'adding template field %s' % key) - func = compile_expr(value) + func = compile_expr(view.get(unicode)) if func is not None: InlinePlugin.template_fields[key] = func diff --git a/setup.py b/setup.py index 662c79445..6023c9537 100755 --- a/setup.py +++ b/setup.py @@ -76,7 +76,9 @@ setup(name='beets', 'unidecode', 'musicbrainzngs', 'pyyaml', - ] + (['colorama'] if (sys.platform == 'win32') else []), + ] + + (['colorama'] if (sys.platform == 'win32') else []) + + (['ordereddict'] if sys.version_info < (2, 7, 0) else []), classifiers=[ 'Topic :: Multimedia :: Sound/Audio', diff --git a/test/_common.py b/test/_common.py index 8bee77d9d..d730d0e0a 100644 --- a/test/_common.py +++ b/test/_common.py @@ -89,11 +89,9 @@ class TestCase(unittest.TestCase): """ def setUp(self): self.old_sources = copy.deepcopy(beets.config.sources) - self.old_overlay = copy.deepcopy(beets.config.overlay) def tearDown(self): beets.config.sources = self.old_sources - beets.config.overlay = self.old_overlay def assertExists(self, path): self.assertTrue(os.path.exists(path), diff --git a/test/test_ui.py b/test/test_ui.py index 3f12c71f8..8444a9e35 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -505,7 +505,7 @@ class ConfigTest(_common.TestCase): config_yaml = textwrap.dedent(config_yaml).strip() if config_yaml: config_data = yaml.load(config_yaml, Loader=confit.Loader) - config.sources.insert(0, config_data) + config.set(config_data) ui._raw_main(args + ['test']) def test_paths_section_respected(self): @@ -515,7 +515,7 @@ class ConfigTest(_common.TestCase): self.assertEqual(template.original, 'y') self._run_main([], """ paths: - - x: y + x: y """, func) def test_default_paths_preserved(self): @@ -525,7 +525,7 @@ class ConfigTest(_common.TestCase): default_formats) self._run_main([], """ paths: - - x: y + x: y """, func) def test_nonexistant_config_file(self): @@ -546,7 +546,7 @@ class ConfigTest(_common.TestCase): self.assertEqual(replacements, [(re.compile(ur'[xy]'), u'z')]) self._run_main([], """ replace: - - '[xy]': z + '[xy]': z """, func) def test_multiple_replacements_parsed(self): @@ -558,8 +558,8 @@ class ConfigTest(_common.TestCase): ]) self._run_main([], """ replace: - - '[xy]': z - - foo: bar + '[xy]': z + foo: bar """, func) class ShowdiffTest(_common.TestCase): @@ -714,7 +714,7 @@ class PathFormatTest(_common.TestCase): def test_custom_paths_prepend(self): default_formats = ui.get_path_formats() - config['paths'] = [('foo', 'bar')] + config['paths'] = {u'foo': u'bar'} pf = ui.get_path_formats() key, tmpl = pf[0] self.assertEqual(key, 'foo')