mirror of
https://github.com/beetbox/beets.git
synced 2025-12-24 01:25:47 +01:00
sync with latest confit
This includes Confit's shift to using OrderedDict, which needs some debugging.
This commit is contained in:
parent
85600df2be
commit
55cac36d35
7 changed files with 155 additions and 89 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
setup.py
4
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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue