sync with latest confit

This includes Confit's shift to using OrderedDict, which needs some debugging.
This commit is contained in:
Adrian Sampson 2012-12-18 20:03:52 -08:00
parent 85600df2be
commit 55cac36d35
7 changed files with 155 additions and 89 deletions

View file

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

View file

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

View file

@ -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 '<ConfigView: %s>' % 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):

View file

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

View file

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

View file

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

View file

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