Skip to content

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also compare across forks.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also compare across forks.
...
  • 2 commits
  • 6 files changed
  • 0 commit comments
  • 1 contributor
Commits on Aug 31, 2012
@sampsyo sampsyo add Confit and start config changeover
This adds a snapshot of the current Confit source (not a crime because Confit is
currently unreleased). It also changes around the bootstrapping mechanisms
enough to let "beet ls" run with the new Confit-based configuration. There's
much more to do.
fe2a687
@sampsyo sampsyo confit: fix unicode and numeric validation 8bc563f
Showing with 552 additions and 106 deletions.
  1. +4 −0 beets/__init__.py
  2. +37 −0 beets/config_default.yaml
  3. +19 −99 beets/ui/__init__.py
  4. +2 −7 beets/ui/commands.py
  5. +489 −0 beets/util/confit.py
  6. +1 −0 setup.py
View
4 beets/__init__.py
@@ -16,4 +16,8 @@
__author__ = 'Adrian Sampson <adrian@radbox.org>'
import beets.library
+from beets.util import confit
+
Library = beets.library.Library
+
+config = confit.Configuration('beets', __name__)
View
37 beets/config_default.yaml
@@ -0,0 +1,37 @@
+library: ~/.beetsmusic.blb
+directory: ~/Music
+
+import_write: yes
+import_copy: yes
+import_move: no
+import_resume: ask
+import_incremental: yes
+import_quiet_fallback: skip
+import_timid: no
+import_log:
+
+ignore: [".*", "*~"]
+replace:
+ '[\\/]': _
+ '^\.': _
+ '[\x00-\x1f]': _
+ '[<>:"\?\*\|]': _
+ '\.$': _
+ '\s+$': ''
+art_filename: cover
+
+plugins: []
+pluginpath: []
+threaded: yes
+color: yes
+timeout: 5.0
+per_disc_numbering: no
+verbose: no
+
+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
View
118 beets/ui/__init__.py
@@ -22,19 +22,19 @@
import locale
import optparse
import textwrap
-import ConfigParser
import sys
from difflib import SequenceMatcher
import logging
import sqlite3
import errno
import re
-import codecs
from beets import library
from beets import plugins
from beets import util
from beets.util.functemplate import Template
+from beets import config
+from beets.util import confit
# On Windows platforms, use colorama to support "ANSI" terminal colors.
@@ -304,27 +304,6 @@ def input_yn(prompt, require=False, color=False):
)
return sel == 'y'
-def config_val(config, section, name, default, vtype=None):
- """Queries the configuration file for a value (given by the section
- and name). If no value is present, returns default. vtype
- optionally specifies the return type (although only ``bool`` and
- ``list`` are supported for now).
- """
- if not config.has_section(section):
- config.add_section(section)
-
- try:
- if vtype is bool:
- return config.getboolean(section, name)
- elif vtype is list:
- # Whitespace-separated strings.
- strval = config.get(section, name, True)
- return strval.split()
- else:
- return config.get(section, name, True)
- except ConfigParser.NoOptionError:
- return default
-
def human_bytes(size):
"""Formats size, a number of bytes, in a human-readable way."""
suffices = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'HB']
@@ -428,33 +407,6 @@ def colordiff(a, b, highlight='red'):
return u''.join(a_out), u''.join(b_out)
-def default_paths(pathmod=None):
- """Produces the appropriate default config, library, and directory
- paths for the current system. On Unix, this is always in ~. On
- Windows, tries ~ first and then $APPDATA for the config and library
- files (for backwards compatibility).
- """
- pathmod = pathmod or os.path
- windows = pathmod.__name__ == 'ntpath'
- if windows:
- windata = os.environ.get('APPDATA') or '~'
-
- # Shorthand for joining paths.
- def exp(*vals):
- return pathmod.expanduser(pathmod.join(*vals))
-
- config = exp('~', DEFAULT_CONFIG_FILENAME_UNIX)
- if windows and not pathmod.exists(config):
- config = exp(windata, DEFAULT_CONFIG_FILENAME_WINDOWS)
-
- libpath = exp('~', DEFAULT_LIBRARY_FILENAME_UNIX)
- if windows and not pathmod.exists(libpath):
- libpath = exp(windata, DEFAULT_LIBRARY_FILENAME_WINDOWS)
-
- libdir = exp('~', DEFAULT_DIRECTORY_NAME)
-
- return config, libpath, libdir
-
def _get_replacements(config):
"""Given a ConfigParser, get the list of replacement pairs. If no
replacements are specified, returns None. Otherwise, returns a list
@@ -684,41 +636,18 @@ def _raw_main(args, configfh):
# Get the default subcommands.
from beets.ui.commands import default_commands
- # Get default file paths.
- default_config, default_libpath, default_dir = default_paths()
-
- # Read defaults from config file.
- config = ConfigParser.SafeConfigParser()
- if configfh:
- configpath = None
- elif CONFIG_PATH_VAR in os.environ:
- configpath = os.path.expanduser(os.environ[CONFIG_PATH_VAR])
- else:
- configpath = default_config
- if configpath:
- configpath = util.syspath(configpath)
- if os.path.exists(util.syspath(configpath)):
- configfh = codecs.open(configpath, 'r', encoding='utf-8')
- else:
- configfh = None
- if configfh:
- config.readfp(configfh)
-
# Add plugin paths.
- plugpaths = config_val(config, 'beets', 'pluginpath', '')
- for plugpath in plugpaths.split(':'):
+ for plugpath in config['pluginpath'].get(list):
sys.path.append(os.path.expanduser(plugpath))
# Load requested plugins.
- plugnames = config_val(config, 'beets', 'plugins', '')
- plugins.load_plugins(plugnames.split())
+ plugins.load_plugins(config['plugins'].get(list))
plugins.send("pluginload")
- plugins.configure(config)
# Construct the root parser.
commands = list(default_commands)
commands += plugins.commands()
parser = SubcommandsOptionParser(subcommands=commands)
- parser.add_option('-l', '--library', dest='libpath',
+ parser.add_option('-l', '--library', dest='library',
help='library database file to use')
parser.add_option('-d', '--directory', dest='directory',
help="destination music directory")
@@ -727,39 +656,28 @@ def _raw_main(args, configfh):
# Parse the command-line!
options, subcommand, suboptions, subargs = parser.parse_args(args)
+ config.add_args(options)
# Open library file.
- libpath = options.libpath or \
- config_val(config, 'beets', 'library', default_libpath)
- directory = options.directory or \
- config_val(config, 'beets', 'directory', default_dir)
- path_formats = _get_path_formats(config)
- art_filename = \
- config_val(config, 'beets', 'art_filename', DEFAULT_ART_FILENAME)
- lib_timeout = config_val(config, 'beets', 'timeout', DEFAULT_TIMEOUT)
- replacements = _get_replacements(config)
- try:
- lib_timeout = float(lib_timeout)
- except ValueError:
- lib_timeout = DEFAULT_TIMEOUT
- db_path = os.path.expanduser(libpath)
try:
- lib = library.Library(db_path,
- directory,
- path_formats,
- art_filename,
- lib_timeout,
- replacements)
+ lib = library.Library(
+ config['library'].get(confit.as_filename),
+ config['directory'].get(confit.as_filename),
+ config['paths'].get(dict), # FIXME
+ config['art_filename'].get(unicode),
+ config['timeout'].get(confit.as_number),
+ config['replace'].get(dict),
+ )
except sqlite3.OperationalError:
- raise UserError("database file %s could not be opened" % db_path)
+ raise UserError("database file %s could not be opened" % FIXME)
plugins.send("library_opened", lib=lib)
# Configure the logger.
- if options.verbose:
+ if config['verbose'].get(bool):
log.setLevel(logging.DEBUG)
else:
log.setLevel(logging.INFO)
- log.debug(u'config file: %s' % util.displayable_path(configpath))
+ log.debug(u'data directory: %s' % util.displayable_path('FIXME'))
log.debug(u'library database: %s' % util.displayable_path(lib.path))
log.debug(u'library directory: %s' % util.displayable_path(lib.directory))
@@ -779,6 +697,8 @@ def main(args=None, configfh=None):
except util.HumanReadableException as exc:
exc.log(log)
sys.exit(1)
+ except confit.ConfigError as exc:
+ xxx
except IOError as exc:
if exc.errno == errno.EPIPE:
# "Broken pipe". End silently.
View
9 beets/ui/commands.py
@@ -808,9 +808,6 @@ def import_func(lib, config, opts, args):
# list: Query and show library contents.
-DEFAULT_LIST_FORMAT_ITEM = '$artist - $album - $title'
-DEFAULT_LIST_FORMAT_ALBUM = '$albumartist - $album'
-
def list_items(lib, query, album, path, fmt):
"""Print out items in lib matching query. If album, then search for
albums instead of single items. If path, print the matched objects'
@@ -843,11 +840,9 @@ def list_func(lib, config, opts, args):
if not fmt:
# If no format is specified, fall back to a default.
if opts.album:
- fmt = ui.config_val(config, 'beets', 'list_format_album',
- DEFAULT_LIST_FORMAT_ALBUM)
+ fmt = config['list_format_album'].get(unicode)
else:
- fmt = ui.config_val(config, 'beets', 'list_format_item',
- DEFAULT_LIST_FORMAT_ITEM)
+ fmt = config['list_format_item'].get(unicode)
list_items(lib, decargs(args), opts.album, opts.path, fmt)
list_cmd.func = list_func
default_commands.append(list_cmd)
View
489 beets/util/confit.py
@@ -0,0 +1,489 @@
+# This file is part of Confit.
+# Copyright 2012, Adrian Sampson.
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""Worry-free YAML configuration files.
+"""
+from __future__ import unicode_literals
+import platform
+import os
+import pkgutil
+import sys
+import yaml
+
+UNIX_DIR_VAR = 'XDG_DATA_HOME'
+UNIX_DIR_FALLBACK = '~/.config'
+WINDOWS_DIR_VAR = 'APPDATA'
+WINDOWS_DIR_FALLBACK = '~\\AppData\\Roaming'
+MAC_DIR = '~/Library/Application Support'
+
+CONFIG_FILENAME = 'config.yaml'
+DEFAULT_FILENAME = 'config_default.yaml'
+
+
+# Utilities.
+
+PY3 = sys.version_info[0] == 3
+STRING = str if PY3 else unicode
+NUMERIC_TYPES = [int, float] if PY3 else [int, float, long]
+
+def iter_first(sequence):
+ """Get the first element from an iterable or raise a ValueError if
+ the iterator generates no values.
+ """
+ it = iter(sequence)
+ try:
+ if PY3:
+ return next(it)
+ else:
+ return it.next()
+ except StopIteration:
+ raise ValueError()
+
+
+# Exceptions.
+
+class ConfigError(Exception):
+ """Base class for exceptions raised when querying a configuration.
+ """
+
+class NotFoundError(ConfigError):
+ """A requested value could not be found in the configuration trees.
+ """
+
+class ConfigTypeError(ConfigError, TypeError):
+ """The value in the configuration did not match the expected type.
+ """
+
+class ConfigValueError(ConfigError, ValueError):
+ """The value in the configuration is illegal."""
+
+class ConfigReadError(ConfigError):
+ """A configuration file could not be read."""
+ def __init__(self, filename, reason=None):
+ self.filename = filename
+ self.reason = reason
+ message = 'file {0} could not be read'.format(filename)
+ if reason:
+ message += ': {0}'.format(reason)
+ super(ConfigReadError, self).__init__(message)
+
+
+# Views and data access logic.
+
+class ConfigView(object):
+ """A configuration "view" is a query into a program's configuration
+ data. A view represents a hypothetical location in the configuration
+ tree; to extract the data from the location, a client typically
+ calls the ``view.get()`` method. The client can access children in
+ the tree (subviews) by subscripting the parent view (i.e.,
+ ``view[key]``).
+ """
+
+ name = None
+ """The name of the view, depicting the path taken through the
+ 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
+ value for each view.) If no values are available, no values are
+ generated. If a type error is encountered when traversing a
+ source to resolve the view, a ConfigTypeError may be raised.
+ """
+ raise NotImplementedError
+
+ def get(self, typ=None):
+ """Returns the canonical value for the view. This amounts to the
+ first item in ``view.get_all()``. If the view cannot be
+ resolved, this method raises a NotFoundError.
+ """
+ values = self.get_all()
+
+ # Get the first value.
+ try:
+ value = iter_first(values)
+ except ValueError:
+ raise NotFoundError("{0} not found".format(self.name))
+
+ # Validate/convert.
+ if isinstance(typ, type):
+ # Check type of value.
+ if not isinstance(value, typ):
+ raise ConfigTypeError(
+ "{0} must be of type {1}, not {2}".format(
+ self.name, typ.__name__, type(value).__name__
+ )
+ )
+
+ elif typ is not None:
+ # typ must a callable that takes this view and the value.
+ value = typ(self, value)
+
+ return value
+
+ def __repr__(self):
+ return '<ConfigView: %s>' % self.name
+
+ def __getitem__(self, key):
+ """Get a subview of this view."""
+ return Subview(self, key)
+
+ def __setitem__(self, key, value):
+ """Set a value in the transient overlay for a certain key under
+ this view.
+ """
+ self.overlay[key] = value
+
+ # Magical conversions. These special methods make it possible to use
+ # View objects somewhat transparently in certain circumstances. For
+ # example, rather than using ``view.get(bool)``, it's possible to
+ # just say ``bool(view)`` or use ``view`` in a conditional.
+
+ 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.)
+ """
+ return self.__bool__()
+
+ def __bool__(self):
+ """Gets the value for this view as a boolean. (Python 3 only.)
+ """
+ return bool(self.get())
+
+ # Dictionary emulation methods.
+
+ def keys(self):
+ """Returns an iterable containing all the keys available as
+ subviews of the current views. This enumerates all the keys in
+ *all* dictionaries matching the current view, in contrast to
+ ``dict(view).keys()``, which gets all the keys for the *first*
+ dict matching the view. If the object for this view in any
+ source is not a dict, then a ConfigTypeError is raised.
+ """
+ keys = set()
+ for dic in self.get_all():
+ try:
+ cur_keys = dic.keys()
+ except AttributeError:
+ raise ConfigTypeError(
+ '{0} must be a dict, not {1}'.format(
+ self.name, type(dic).__name__
+ )
+ )
+ keys.update(cur_keys)
+ return keys
+
+ def items(self):
+ """Iterates over (key, subview) pairs contained in dictionaries
+ from *all* sources at this view. If the object for this view in
+ any source is not a dict, then a ConfigTypeError is raised.
+ """
+ for key in self.keys():
+ yield key, self[key]
+
+ def values(self):
+ """Iterates over all the subviews contained in dictionaries from
+ *all* sources at this view. If the object for this view in any
+ source is not a dict, then a ConfigTypeError is raised.
+ """
+ for key in self.keys():
+ yield self[key]
+
+ # List/sequence emulation.
+
+ def all_contents(self):
+ """Iterates over all subviews from collections at this view from
+ *all* sources. If the object for this view in any source is not
+ iterable, then a ConfigTypeError is raised. This method is
+ intended to be used when the view indicates a list; this method
+ will concatenate the contents of the list from all sources.
+ """
+ for collection in self.get_all():
+ try:
+ it = iter(collection)
+ except TypeError:
+ raise ConfigTypeError(
+ '{0} must be an iterable, not {1}'.format(
+ self.name, type(collection).__name__
+ )
+ )
+ for value in it:
+ yield value
+
+class RootView(ConfigView):
+ """The base of a view hierarchy. This view keeps track of the
+ sources that may be accessed by subviews.
+ """
+ def __init__(self, sources):
+ """Create a configuration hierarchy for a list of sources. At
+ least one source must be provided. The first source in the list
+ has the highest priority.
+ """
+ self.sources = list(sources)
+ self.overlay = {}
+ self.name = 'root'
+
+ def add(self, obj):
+ """Add the object (probably a dict) as a source for
+ configuration data. The object as added as the lowest-priority
+ source. This can be used to dynamically extend the defaults
+ (i.e., when loading a plugin that shares the main application's
+ config file).
+ """
+ self.sources.append(obj)
+
+ def get_all(self):
+ return [self.overlay] + self.sources
+
+class Subview(ConfigView):
+ """A subview accessed via a subscript of a parent view."""
+ def __init__(self, parent, key):
+ """Make a subview of a parent view for a given subscript key.
+ """
+ self.parent = parent
+ self.key = key
+ self.name = '{0}[{1}]'.format(self.parent.name, repr(self.key))
+
+ def get_all(self):
+ for collection in self.parent.get_all():
+ try:
+ value = collection[self.key]
+ except IndexError:
+ # List index out of bounds.
+ continue
+ except KeyError:
+ # Dict key does not exist.
+ continue
+ except TypeError:
+ # Not subscriptable.
+ raise ConfigTypeError(
+ "{0} must be a collection, not {1}".format(
+ self.parent.name, type(collection).__name__
+ )
+ )
+ 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]
+
+
+# Config file paths, including platform-specific paths and in-package
+# defaults.
+
+# Based on get_root_path from Flask by Armin Ronacher.
+def _package_path(name):
+ """Returns the path to the package containing the named module or
+ None if the path could not be identified (e.g., if
+ ``name == "__main__"``).
+ """
+ loader = pkgutil.get_loader(name)
+ if loader is None or name == '__main__':
+ return None
+
+ if hasattr(loader, 'get_filename'):
+ filepath = loader.get_filename(name)
+ else:
+ # Fall back to importing the specified module.
+ __import__(name)
+ filepath = sys.modules[name].__file__
+
+ return os.path.dirname(os.path.abspath(filepath))
+
+def config_dirs():
+ """Returns a list of user configuration directories to be searched.
+ """
+ if platform.system() == 'Darwin':
+ paths = [MAC_DIR, UNIX_DIR_FALLBACK]
+ elif platform.system() == 'Windows':
+ if WINDOWS_DIR_VAR in os.environ:
+ paths = [os.environ[WINDOWS_DIR_VAR]]
+ else:
+ paths = [WINDOWS_DIR_FALLBACK]
+ else:
+ # Assume Unix.
+ paths = [UNIX_DIR_FALLBACK]
+ if UNIX_DIR_VAR in os.environ:
+ paths.insert(0, os.environ[UNIX_DIR_VAR])
+
+ # Expand and deduplicate paths.
+ out = []
+ for path in paths:
+ path = os.path.abspath(os.path.expanduser(path))
+ if path not in out:
+ out.append(path)
+ return out
+
+
+# Validation and conversion helpers.
+
+def as_filename(view, value):
+ """Gets a string as a normalized filename, made absolute and with
+ tilde expanded.
+ """
+ value = STRING(value)
+ return os.path.abspath(os.path.expanduser(value))
+
+def as_choice(choices):
+ """Returns a function that ensures that the value is one of a
+ collection of choices.
+ """
+ def f(view, value):
+ if value not in choices:
+ raise ConfigValueError(
+ '{0} must be one of {1}, not {2}'.format(
+ view.name, repr(value), repr(list(choices))
+ )
+ )
+ return value
+ return f
+
+def as_number(view, value):
+ """Ensure that a value is of numeric type."""
+ for typ in NUMERIC_TYPES:
+ if isinstance(value, typ):
+ return value
+ raise ConfigTypeError(
+ '{0} must be numeric, not {1}'.format(
+ view.name, type(value).__name__
+ )
+ )
+
+
+# YAML.
+
+class Loader(yaml.SafeLoader):
+ """A customized YAML safe loader that reads all strings as Unicode
+ objects.
+ """
+ def _construct_unicode(self, node):
+ return self.construct_scalar(node)
+Loader.add_constructor('tag:yaml.org,2002:str', Loader._construct_unicode)
+
+def load_yaml(filename):
+ """Read a YAML document from a file. If the file cannot be read or
+ parsed, a ConfigReadError is raised.
+ """
+ try:
+ with open(filename, 'r') as f:
+ return yaml.load(f, Loader=Loader)
+ except (IOError, yaml.error.YAMLError) as exc:
+ raise ConfigReadError(filename, exc)
+
+
+# Main interface.
+
+class Configuration(RootView):
+ def __init__(self, name, 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.)
+ """
+ super(Configuration, self).__init__([])
+ self.name = name
+ self.modname = modname
+
+ self._env_var = '{0}DIR'.format(self.name.upper())
+
+ if read:
+ self._read()
+
+ def _search_dirs(self):
+ """Yield directories that will be searched for configuration
+ files for this application.
+ """
+ # Application's environment variable.
+ if self._env_var in os.environ:
+ path = os.environ[self._env_var]
+ yield os.path.abspath(os.path.expanduser(path))
+
+ # Standard configuration directories.
+ for confdir in config_dirs():
+ yield os.path.join(confdir, self.name)
+
+ def _filenames(self):
+ """Get a list of filenames for configuration files. The files
+ actually exist and are in the order that they should be
+ prioritized.
+ """
+ out = []
+
+ # Search standard directories.
+ for appdir in self._search_dirs():
+ out.append(os.path.join(appdir, CONFIG_FILENAME))
+
+ # Search the package for a defaults file.
+ if 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.
+ """
+ self.sources = []
+ for filename in self._filenames():
+ self.sources.append(load_yaml(filename))
+
+ def add_args(self, namespace):
+ """Add parsed command-line arguments, generated by a library
+ like argparse or optparse, as an overlay to the configuration
+ sources.
+ """
+ arg_source = {}
+ for key, value in namespace.__dict__.items():
+ if value is not None: # Avoid unset options.
+ arg_source[key] = value
+ self.sources.insert(0, arg_source)
+
+ def config_dir(self):
+ """Get the path to the directory containing the highest-priority
+ user configuration. If no user configuration is present, create a
+ suitable directory before returning it.
+ """
+ dirs = list(self._search_dirs())
+
+ # First, look for an existant configuration file.
+ for appdir in dirs:
+ if os.path.isfile(os.path.join(appdir, CONFIG_FILENAME)):
+ return appdir
+
+ # As a fallback, create the first-listed directory name.
+ appdir = dirs[0]
+ if not os.path.isdir(appdir):
+ os.makedirs(appdir)
+ return appdir
View
1 setup.py
@@ -75,6 +75,7 @@ def _read(fn):
'munkres',
'unidecode',
'musicbrainzngs',
+ 'pyyaml',
] + (['colorama'] if (sys.platform == 'win32') else []),
classifiers=[

No commit comments for this range

Something went wrong with that request. Please try again.