From 123189b393ba3b2a45c2315bcaf512a918367fb8 Mon Sep 17 00:00:00 2001 From: Adrian Sampson Date: Sun, 23 Dec 2012 18:01:21 -0800 Subject: [PATCH] default file locations Due mostly to some improvements in Confit, we now have a reasonable way to define the default filenames of auxiliary data files. These are relative to the beets config directory (i.e., alongside config.yaml). --- beets/config_default.yaml | 4 +- beets/importer.py | 5 +- beets/util/confit.py | 192 +++++++++++++++++++++++++------------- docs/changelog.rst | 4 + 4 files changed, 138 insertions(+), 67 deletions(-) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index 01e4e8758..71d807269 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -1,4 +1,4 @@ -library: ~/.beetsmusic.blb +library: library.db directory: ~/Music import: @@ -40,3 +40,5 @@ paths: default: $albumartist/$album%aunique{}/$track $title singleton: Non-Album/$artist/$title comp: Compilations/$album%aunique{}/$track $title + +statefile: state.pickle diff --git a/beets/importer.py b/beets/importer.py index bbc87d952..a39ff82f3 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -38,7 +38,6 @@ action = enum( ) QUEUE_SIZE = 128 -STATE_FILE = os.path.expanduser('~/.beetsstate') SINGLE_ARTIST_THRESH = 0.25 VARIOUS_ARTISTS = u'Various Artists' @@ -146,14 +145,14 @@ def _resume(): def _open_state(): """Reads the state file, returning a dictionary.""" try: - with open(STATE_FILE) as f: + with open(config['statefile'].as_filename()) as f: return pickle.load(f) except (IOError, EOFError): return {} def _save_state(state): """Writes the state dictionary out to disk.""" try: - with open(STATE_FILE, 'w') as f: + with open(config['statefile'].as_filename(), 'w') as f: pickle.dump(state, f) except IOError as exc: log.error(u'state file could not be written: %s' % unicode(exc)) diff --git a/beets/util/confit.py b/beets/util/confit.py index 7a034c9cc..01191d8ce 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -87,7 +87,38 @@ class ConfigReadError(ConfigError): super(ConfigReadError, self).__init__(message) -# Views and data access logic. +# Views and sources. + +class ConfigSource(dict): + """A dictionary augmented with metadata about the source of the + configuration. + """ + def __init__(self, value, filename=None, default=False): + super(ConfigSource, self).__init__(value) + if filename is not None and not isinstance(filename, BASESTRING): + raise TypeError('filename must be a string or None') + self.filename = filename + self.default = default + + def __repr__(self): + return 'ConfigSource({0}, {1}, {2})'.format( + super(ConfigSource, self).__repr__(), + repr(self.filename), + repr(self.default) + ) + + @classmethod + def of(self, value): + """Given either a dictionary or a `ConfigSource` object, return + a `ConfigSource` object. This lets a function accept either type + of object as an argument. + """ + if isinstance(value, ConfigSource): + return value + elif isinstance(value, dict): + return ConfigSource(value) + else: + raise TypeError('source value must be a dict') class ConfigView(object): """A configuration "view" is a query into a program's configuration @@ -103,42 +134,26 @@ class ConfigView(object): configuration in Python-like syntax (e.g., ``foo['bar'][42]``). """ - 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. + def resolve(self): + """The core (internal) data retrieval method. Generates (value, + source) pairs for each source that contains a value for this + view. May raise ConfigTypeError if a type error occurs while + traversing a source. """ 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. + def first(self): + """Returns a (value, source) pair for the first object found for + this view. This amounts to the first element returned by + `resolve`. If no values are available, a NotFoundError is + raised. """ - values = self.get_all() - - # Get the first value. + pairs = self.resolve() try: - value = iter_first(values) + return iter_first(pairs) except ValueError: raise NotFoundError("{0} not found".format(self.name)) - # Validate type. - if typ is not None: - if not isinstance(typ, TYPE_TYPES): - raise TypeError('argument to get() must be a type') - - if not isinstance(value, typ): - raise ConfigTypeError( - "{0} must be of type {1}, not {2}".format( - self.name, typ.__name__, type(value).__name__ - ) - ) - - return value - def add(self, value): """Set the *default* value for this configuration view. The specified value is added as the lowest-priority configuration @@ -153,6 +168,11 @@ class ConfigView(object): """ raise NotImplementedError + def root(self): + """The RootView object from which this view is descended. + """ + raise NotImplementedError + def __repr__(self): return '' % self.name @@ -214,7 +234,7 @@ class ConfigView(object): """ keys = [] - for dic in self.get_all(): + for dic, _ in self.resolve(): try: cur_keys = dic.keys() except AttributeError: @@ -255,7 +275,7 @@ class ConfigView(object): 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(): + for collection, _ in self.resolve(): try: it = iter(collection) except TypeError: @@ -267,14 +287,49 @@ class ConfigView(object): for value in it: yield value - # Explicit validators/converters. + # Validation and conversion. + + def get(self, typ=None): + """Returns the canonical value for the view, checked against the + passed-in type. If the value is not an instance of the given + type, a ConfigTypeError is raised. May also raise a + NotFoundError. + """ + value, _ = self.first() + + if typ is not None: + if not isinstance(typ, TYPE_TYPES): + raise TypeError('argument to get() must be a type') + + if not isinstance(value, typ): + raise ConfigTypeError( + "{0} must be of type {1}, not {2}".format( + self.name, typ.__name__, type(value).__name__ + ) + ) + + return value def as_filename(self): """Get a string as a normalized filename, made absolute and with - tilde expanded. + tilde expanded. If the value comes from a default source, the + path is considered relative to the application's config + directory. If it comes from another file source, the filename is + expanded as if it were relative to that directory. Otherwise, it + is relative to the current working directory. """ - value = STRING(self.get()) - return os.path.abspath(os.path.expanduser(value)) + path, source = self.first() + path = os.path.expanduser(STRING(path)) + + if source.default: + # From defaults: relative to the app's directory. + path = os.path.join(self.root().config_dir(), path) + + elif source.filename is not None: + # Relative to source filename's directory. + path = os.path.join(os.path.dirname(source.filename), path) + + return os.path.abspath(path) def as_choice(self, choices): """Ensure that the value is among a collection of choices and @@ -333,13 +388,20 @@ class RootView(ConfigView): self.name = ROOT_NAME def add(self, obj): - self.sources.append(obj) + self.sources.append(ConfigSource.of(obj)) def set(self, value): - self.sources.insert(0, value) + self.sources.insert(0, ConfigSource.of(value)) - def get_all(self): - return self.sources + def resolve(self): + return ((dict(s), s) for s in self.sources) + + def clear(self): + """Remove all sources from this configuration.""" + del self.sources[:] + + def root(self): + return self class Subview(ConfigView): """A subview accessed via a subscript of a parent view.""" @@ -363,8 +425,8 @@ class Subview(ConfigView): else: self.name += '{0}'.format(repr(self.key)) - def get_all(self): - for collection in self.parent.get_all(): + def resolve(self): + for collection, source in self.parent.resolve(): try: value = collection[self.key] except IndexError: @@ -380,7 +442,7 @@ class Subview(ConfigView): self.parent.name, type(collection).__name__ ) ) - yield value + yield value, source def set(self, value): self.parent.set({self.key: value}) @@ -388,6 +450,9 @@ class Subview(ConfigView): def add(self, value): self.parent.add({self.key: value}) + def root(self): + return self.parent.root() + # Config file paths, including platform-specific paths and in-package # defaults. @@ -539,28 +604,25 @@ class Configuration(RootView): for confdir in config_dirs(): yield os.path.join(confdir, self.appname) - def _filenames(self, user=True, defaults=True): - """Get a list of filenames for configuration files. The files - 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. + def _user_sources(self): + """Generate `ConfigSource` objects for each user configuration + file in the program's search directories. """ - out = [] + for appdir in self._search_dirs(): + filename = os.path.join(appdir, CONFIG_FILENAME) + if os.path.isfile(filename): + yield ConfigSource(load_yaml(filename), filename) - # Search standard directories. - if user: - for appdir in self._search_dirs(): - out.append(os.path.join(appdir, CONFIG_FILENAME)) - - # Search the package for a defaults file. - if defaults and self.modname: + def _default_source(self): + """Return the default-value source for this program or `None` if + it does not exist. + """ + 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)] + filename = os.path.join(pkg_path, DEFAULT_FILENAME) + if os.path.isfile(filename): + return ConfigSource(load_yaml(filename), filename, True) def read(self, user=True, defaults=True): """Find and read the files for this configuration and set them @@ -568,9 +630,13 @@ class Configuration(RootView): discovered user configuration files or the in-package defaults, set `user` or `defaults` to `False`. """ - self.sources = [] - for filename in self._filenames(user, defaults): - self.sources.append(load_yaml(filename)) + if user: + for source in self._user_sources(): + self.add(source) + if defaults: + source = self._default_source() + if source: + self.add(source) def config_dir(self): """Get the path to the directory containing the highest-priority diff --git a/docs/changelog.rst b/docs/changelog.rst index b15342645..c221d62dc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,10 @@ This release entirely revamps beets' configuration system. ``autoembed``, etc.) but is now consistently called ``auto``. * Reorganized import config options: The various ``import_*`` options are now organized under an ``import:`` heading and their prefixes have been removed. +* New default file locations: The default filename of the library database is + now ``library.db`` in the same directory as the config file, as opposed to + ``~/.beetsmusic.blb`` previously. Similarly, the runtime state file is now + called ``state.pickle`` in the same directory instead of ``~/.beetsstate``. 1.0rc2 (in development) -----------------------