diff --git a/beets/util/confit.py b/beets/util/confit.py index 4048c89bb..aa0bd1e1b 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -332,12 +332,11 @@ class ConfigView(object): return value def as_filename(self): - """Get a string as a normalized filename, made absolute and with - 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. + """Get a string as a normalized as an absolute path. + + If the value is a relative path it is expanded relative to the + root configuration's ``config_dir()``. Tilde is also expaned + with ``os.path.expanduser()``. """ path, source = self.first() if not isinstance(path, BASESTRING): @@ -346,14 +345,10 @@ class ConfigView(object): )) path = os.path.expanduser(STRING(path)) - if source.default: + if not os.path.isabs(path): # 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): @@ -508,20 +503,24 @@ def _package_path(name): return os.path.dirname(os.path.abspath(filepath)) def config_dirs(): - """Returns a list of user configuration directories to be searched. + """Returns a list of candidates for user configuration directories + on the system. """ + paths = [] if platform.system() == 'Darwin': - paths = [UNIX_DIR_FALLBACK, MAC_DIR] + paths.append(UNIX_DIR_FALLBACK) + paths.append(MAC_DIR) + if UNIX_DIR_VAR in os.environ: + paths.append(os.environ[UNIX_DIR_VAR]) elif platform.system() == 'Windows': + paths.append(WINDOWS_DIR_FALLBACK) if WINDOWS_DIR_VAR in os.environ: - paths = [os.environ[WINDOWS_DIR_VAR]] - else: - paths = [WINDOWS_DIR_FALLBACK] + paths.append(os.environ[WINDOWS_DIR_VAR]) else: # Assume Unix. - paths = [UNIX_DIR_FALLBACK] + paths.append(UNIX_DIR_FALLBACK) if UNIX_DIR_VAR in os.environ: - paths.insert(0, os.environ[UNIX_DIR_VAR]) + paths.append(os.environ[UNIX_DIR_VAR]) # Expand and deduplicate paths. out = [] @@ -622,38 +621,24 @@ class Configuration(RootView): if read: self.read() - def _search_dirs(self): - """Yield directories that will be searched for configuration - files for this application. + def _add_user_source(self): + """Add ``ConfigSource`` for the configuration file in + ``config_dir()`` if it exists. """ - # 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)) + filename = os.path.join(self.config_dir(), CONFIG_FILENAME) + if os.path.isfile(filename): + self.add(ConfigSource(load_yaml(filename) or {}, filename)) - # Standard configuration directories. - for confdir in config_dirs(): - yield os.path.join(confdir, self.appname) - - def _user_sources(self): - """Generate `ConfigSource` objects for each user configuration - file in the program's search directories. - """ - for appdir in self._search_dirs(): - filename = os.path.join(appdir, CONFIG_FILENAME) - if os.path.isfile(filename): - yield ConfigSource(load_yaml(filename) or {}, filename) - - def _default_source(self): - """Return the default-value source for this program or `None` if - it does not exist. + def _add_default_source(self): + """Adds ``ConfigSource`` for the default configuration of the + package if it exists """ if self.modname: pkg_path = _package_path(self.modname) if pkg_path: filename = os.path.join(pkg_path, DEFAULT_FILENAME) if os.path.isfile(filename): - return ConfigSource(load_yaml(filename), filename, True) + self.add(ConfigSource(load_yaml(filename), filename, True)) def read(self, user=True, defaults=True): """Find and read the files for this configuration and set them @@ -662,27 +647,32 @@ class Configuration(RootView): set `user` or `defaults` to `False`. """ if user: - for source in self._user_sources(): - self.add(source) + self._add_user_source() if defaults: - source = self._default_source() - if source: - self.add(source) + self._add_default_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. + """Path of the user configuration directory + + If the BEETSDIR environment variable is set it returns its + value. Otherwise look for existing ``beets/config.yaml`` in + ``config_dirs()`` and returns it. If none of the files exists it + return the last path searched and makes sure the directory exists. """ - dirs = list(self._search_dirs()) + if self._env_var in os.environ: + path = os.environ[self._env_var] + return os.path.abspath(os.path.expanduser(path)) + + dirs = [] + for confdir in config_dirs(): + dirs.append(os.path.join(confdir, self.appname)) # First, look for an existent 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] + # Fallback to the last path if not os.path.isdir(appdir): os.makedirs(appdir) return appdir diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index c44ec3a02..9ab18f26e 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -317,7 +317,10 @@ import ...``. * ``-d DIRECTORY``: specify the library root directory. * ``-v``: verbose mode; prints out a deluge of debugging information. Please use this flag when reporting bugs. -* ``-c FILE``: read a specified YAML configuration file. +* ``-c FILE``: read a specified YAML :doc:`configuration file `. + +Beets also uses the ``BEETSDIR`` environment variable to look for +configuration and data. .. only:: man diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 92b3135cb..040db565a 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -5,17 +5,14 @@ Beets has an extensive configuration system that lets you customize nearly every aspect of its operation. To configure beets, you'll edit a file called ``config.yaml``. The location of this file depends on your OS: -* On Unix-like OSes (including OS X), you want ``~/.config/beets/config.yaml``. +* On Unix-like OSes, you want ``~/.config/beets/config.yaml``. * On Windows, use ``%APPDATA%\beets\config.yaml``. This is usually in a directory like ``C:\Users\You\AppData\Roaming``. -* On OS X, you can also use ``~/Library/Application Support/beets/config.yaml`` - if you prefer that over the Unix-like ``~/.config``. -* If you prefer a different location, set the ``BEETSDIR`` environment - variable to a path; beets will then look for a ``config.yaml`` in that - directory. -* Or specify an *additional* configuration file to load using the ``--config - /path/to/file`` option on the command line. The options will be combined - with any options already specified your default config file. +* On OS X, it is ``~/Library/Application Support/beets/config.yaml``. + +It is also possible to customize the location of the configuration file +and even use multiple layers of configuration. Just have a look at +`Configuration Location`_. The config file uses `YAML`_ syntax. You can use the full power of YAML, but most configuration options are simple key/value pairs. This means your config @@ -554,6 +551,64 @@ fact, just shorthand for the explicit queries ``singleton:true`` and ``comp:true``. In contrast, ``default`` is special and has no query equivalent: the ``default`` format is only used if no queries match. + +Configuration Location +---------------------- + +Beets has three layers of configuration; each overwriting the previous +one. + +The first layer is the *default configuration*. It comes with your beets +distribution and cannot be changed. The second configuration layer is +the *user configuration*. Here you can customize the configuration to +use when running ``beet`` on your command line. + +The path for the user configuration is given by +``$BEETSDIR/config.yaml``. Here ``BEETSDIR``, is the directory beets +uses to store its application specific data and resolve relative paths. +By default, ``BEETSDIR`` is determined by your system's convention for +storing application configuration, but may be set by the ``BEETSDIR`` +environment variable. This allows you to manage mutliple beets +libraries with separate configurations. To be more precise, the +following algorithm is used to determine ``BEETSDIR``. + +1. If the ``BEETSDIR`` environment variable is set, then use it and + stop. + +2. Otherwise, generate a platform-dependent list of directories to + search. + + - On Windows: ``~\AppData\Roaming\beets`` and then + ``%APPDATA%\beets``, if the environment variable is set + + - On non-Mac Unixes: ``~/.config/beets`` and then + ``$XDG_CONFIG_DIR/beets``, if the environment variable is set + + - On OS X: ``~/.config/beets``, then + ``~/Library/Application Support/beets``, and finally + ``$XDG_CONFIG_DIR/beets``, if the environment variable is set + +3. Look in each directory in turn for a ``config.yaml``. Set + ``BEETSDIR`` to the *first* directory that contains this file. If no + directory is found containing ``config.yaml``, then use the *last* + directory in the list. + + +Finally, the ``--config CONFIGFILE`` command line option serves as the +third layer. ``CONFIGFILE`` is the path of a YAML file that contains +additional configuration overwriting the user configuration. This is +helpful, for example, if you have different strategies for importing +files, each with its own set of importer configuration. + +In addition some command line options overwrite configuration values. +For example the command :: + + $ beets --library /path/to/lib import --timid /path/to/import + +uses the ``library`` and ``importer.timid`` values from the command line +instead of the user configuration. + + .. _config-example: Example diff --git a/test/test_ui.py b/test/test_ui.py index e5cbf95a8..76b1c405b 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -471,9 +471,15 @@ class ConfigTest(_common.TestCase): self.test_cmd = self._make_test_cmd() commands.default_commands.append(self.test_cmd) - config_dir = os.path.join(self.temp_dir, '.config', 'beets') - os.makedirs(config_dir) - self.user_config_path = os.path.join(config_dir, 'config.yaml') + # Default user configuration + self.user_config_dir = os.path.join(self.temp_dir, '.config', 'beets') + os.makedirs(self.user_config_dir) + self.user_config_path = os.path.join(self.user_config_dir, + 'config.yaml') + + # Custom BEETSDIR + self.beetsdir = os.path.join(self.temp_dir, 'beetsdir') + os.makedirs(self.beetsdir) self._reset_config() @@ -567,27 +573,10 @@ class ConfigTest(_common.TestCase): ui._raw_main(['--config', config_path, 'test']) self.assertEqual(config['anoption'].get(), 'value') - def test_beetsdir_config_file_overwrites_defaults(self): - with open(self.user_config_path, 'w') as file: - file.write('anoption: value') - - env_config_path = os.path.join(self.temp_dir, 'config.yaml') - os.environ['BEETSDIR'] = self.temp_dir - with open(env_config_path, 'w') as file: - file.write('anoption: overwrite') - - ui.main(['test']) - self.assertEqual(config['anoption'].get(), 'overwrite') - def test_cli_config_file_overwrites_user_defaults(self): with open(self.user_config_path, 'w') as file: file.write('anoption: value') - env_config_path = os.path.join(self.temp_dir, 'config.yaml') - os.environ['BEETSDIR'] = self.temp_dir - with open(env_config_path, 'w') as file: - file.write('anoption: overwrite') - cli_config_path = os.path.join(self.temp_dir, 'config.yaml') with open(cli_config_path, 'w') as file: file.write('anoption: cli overwrite') @@ -595,6 +584,77 @@ class ConfigTest(_common.TestCase): ui._raw_main(['--config', cli_config_path, 'test']) self.assertEqual(config['anoption'].get(), 'cli overwrite') + def test_cli_config_file_overwrites_beetsdir_defaults(self): + os.environ['BEETSDIR'] = self.beetsdir + env_config_path = os.path.join(self.beetsdir, 'config.yaml') + with open(env_config_path, 'w') as file: + file.write('anoption: value') + + cli_config_path = os.path.join(self.temp_dir, 'config.yaml') + with open(cli_config_path, 'w') as file: + file.write('anoption: cli overwrite') + + ui._raw_main(['--config', cli_config_path, 'test']) + self.assertEqual(config['anoption'].get(), 'cli overwrite') + + @unittest.skip('Difficult to implement with optparse') + def test_multiple_cli_config_files(self): + cli_config_path_1 = os.path.join(self.temp_dir, 'config.yaml') + cli_config_path_2 = os.path.join(self.temp_dir, 'config_2.yaml') + + with open(cli_config_path_1, 'w') as file: + file.write('first: value') + + with open(cli_config_path_2, 'w') as file: + file.write('second: value') + + ui._raw_main(['--config', cli_config_path_1, + '--config', cli_config_path_2, 'test']) + self.assertEqual(config['first'].get(), 'value') + self.assertEqual(config['second'].get(), 'value') + + @unittest.skip('Difficult to implement with optparse') + def test_multiple_cli_config_overwrite(self): + cli_config_path = os.path.join(self.temp_dir, 'config.yaml') + cli_overwrite_config_path = os.path.join(self.temp_dir, + 'overwrite_config.yaml') + + with open(cli_config_path, 'w') as file: + file.write('anoption: value') + + with open(cli_overwrite_config_path, 'w') as file: + file.write('anoption: overwrite') + + ui._raw_main(['--config', cli_config_path, + '--config', cli_overwrite_config_path, 'test']) + self.assertEqual(config['anoption'].get(), 'cli overwrite') + + def test_cli_config_paths_resolve_relative_to_user_dir(self): + cli_config_path = os.path.join(self.temp_dir, 'config.yaml') + with open(cli_config_path, 'w') as file: + file.write('library: beets.db\n') + file.write('statefile: state') + + ui._raw_main(['--config', cli_config_path, 'test']) + self.assertEqual(config['library'].as_filename(), + os.path.join(self.user_config_dir, 'beets.db')) + self.assertEqual(config['statefile'].as_filename(), + os.path.join(self.user_config_dir, 'state')) + + def test_cli_config_paths_resolve_relative_to_beetsdir(self): + os.environ['BEETSDIR'] = self.beetsdir + + cli_config_path = os.path.join(self.temp_dir, 'config.yaml') + with open(cli_config_path, 'w') as file: + file.write('library: beets.db\n') + file.write('statefile: state') + + ui._raw_main(['--config', cli_config_path, 'test']) + self.assertEqual(config['library'].as_filename(), + os.path.join(self.beetsdir, 'beets.db')) + self.assertEqual(config['statefile'].as_filename(), + os.path.join(self.beetsdir, 'state')) + def test_cli_config_file_loads_plugin_commands(self): plugin_path = os.path.join(_common.RSRC, 'beetsplug') @@ -606,6 +666,48 @@ class ConfigTest(_common.TestCase): ui._raw_main(['--config', cli_config_path, 'plugin']) self.assertTrue(plugins.find_plugins()[0].is_test_plugin) + def test_beetsdir_config(self): + os.environ['BEETSDIR'] = self.beetsdir + + env_config_path = os.path.join(self.beetsdir, 'config.yaml') + with open(env_config_path, 'w') as file: + file.write('anoption: overwrite') + + config.read() + self.assertEqual(config['anoption'].get(), 'overwrite') + + def test_beetsdir_config_does_not_load_default_user_config(self): + os.environ['BEETSDIR'] = self.beetsdir + + with open(self.user_config_path, 'w') as file: + file.write('anoption: value') + + config.read() + self.assertFalse(config['anoption'].exists()) + + def test_default_config_paths_resolve_relative_to_beetsdir(self): + os.environ['BEETSDIR'] = self.beetsdir + + config.read() + self.assertEqual(config['library'].as_filename(), + os.path.join(self.beetsdir, 'library.db')) + self.assertEqual(config['statefile'].as_filename(), + os.path.join(self.beetsdir, 'state.pickle')) + + def test_beetsdir_config_paths_resolve_relative_to_beetsdir(self): + os.environ['BEETSDIR'] = self.beetsdir + + env_config_path = os.path.join(self.beetsdir, 'config.yaml') + with open(env_config_path, 'w') as file: + file.write('library: beets.db\n') + file.write('statefile: state') + + config.read() + self.assertEqual(config['library'].as_filename(), + os.path.join(self.beetsdir, 'beets.db')) + self.assertEqual(config['statefile'].as_filename(), + os.path.join(self.beetsdir, 'state')) + class ShowdiffTest(_common.TestCase): def setUp(self):