diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 84ae353d3..5e88e9bee 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -22,6 +22,8 @@ import os import time import itertools import codecs +import yaml +import platform import beets from beets import ui @@ -34,6 +36,7 @@ from beets import importer from beets import util from beets.util import syspath, normpath, ancestry, displayable_path from beets.util.functemplate import Template +from beets.util.confit import ConfigTypeError from beets import library from beets import config @@ -1245,3 +1248,58 @@ def write_func(lib, opts, args): write_items(lib, decargs(args), opts.pretend) write_cmd.func = write_func default_commands.append(write_cmd) + + +config_cmd = ui.Subcommand('config', help='show or edit the user configuration') +config_cmd.parser.add_option('-p', '--paths', action='store_true', + help='show files that configuration was loaded from') +config_cmd.parser.add_option('-e', '--edit', action='store_true', + help='edit user configuration with $EDITOR') +config_cmd.parser.add_option('-d', '--defaults', action='store_true', + help='include the default configuration') +def _config_get(view): + try: + keys = view.keys() + except ConfigTypeError: + return view.get() + else: + return dict((key, _config_get(view[key])) for key in view.keys()) +def config_func(lib, opts, args): + # Make sure lazy configuration is loaded + config.resolve() + + if not opts.defaults: + # Remove default source + config.sources = [source for source in config.sources if not source.default] + + if opts.paths: + for source in config.sources: + if source.filename: + print(source.filename) + elif opts.edit: + path = config.user_config_path() + + if 'EDITOR' in os.environ: + editor = os.environ['EDITOR'] + args = [editor, editor, path] + elif platform.system() == 'Darwin': + args = ['open', 'open', '-n', path] + elif platform.system() == 'Windows': + # On windows we can execute arbitrary files. The os will + # take care of starting an appropriate application + args = [path, path] + else: + # Assume Unix + args = ['xdg-open', 'xdg-open', path] + + try: + os.execlp(*args) + except OSError: + raise ui.UserError("Could not edit configuration. Please" + "set the EDITOR environment variable.") + else: + config_dict = _config_get(config) + print(yaml.safe_dump(config_dict, default_flow_style=False)) + +config_cmd.func = config_func +default_commands.append(config_cmd) diff --git a/beets/util/confit.py b/beets/util/confit.py index 39f434ffb..11ec08620 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -629,12 +629,19 @@ class Configuration(RootView): if read: self.read() + def user_config_path(self): + """Points to the location of the user configuration. + + The file may not exist. + """ + return os.path.join(self.config_dir(), CONFIG_FILENAME) + def _add_user_source(self): """Add the configuration options from the YAML file in the user's configuration directory (given by `config_dir`) if it exists. """ - filename = os.path.join(self.config_dir(), CONFIG_FILENAME) + filename = self.user_config_path() if os.path.isfile(filename): self.add(ConfigSource(load_yaml(filename) or {}, filename)) @@ -734,3 +741,9 @@ class LazyConfig(Configuration): # Buffer additions to beginning. self._lazy_prefix[:0] = self.sources del self.sources[:] + + def clear(self): + """Remove all sources from this configuration.""" + del self.sources[:] + self._lazy_suffix = [] + self._lazy_prefix = [] diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 9ab18f26e..eb4115e54 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -304,6 +304,30 @@ fields Show the item and album metadata fields available for use in :doc:`query` and :doc:`pathformat`. Includes any template fields provided by plugins. +.. _config-cmd: + +config +`````` +:: + + beet config [-pd] + beet config -e + +Show or edit the user configuration. Without any options this command +prints a YAML representation of the current user configuration. If the +``--path`` option is given it instead prints the aboslute path to the +user configuration file. Note that this path may not exist. The +``--default`` option can be set with or without the ``--path`` option. +If it is set it also load the default configuration from the beets +package. Showing the configuration or the paths also works if an +additional ``--config`` option is given on the command line. + +If the ``--edit`` option is given, beets will open the user configuration +in an editor. If the ``EDITOR`` environment variable is set it uses that +command to start the editor. Otherwise, beets tries the ``open`` command on +OSX, the ``xdg-open`` command on Unixes and will try to execute the +configuration file directly on Windows. + .. _global-flags: Global Flags diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 344d5dbd7..d005f16a3 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -3,13 +3,10 @@ Configuration 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, 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 use either the Unix location or ``~/Library/Application - Support/beets/config.yaml``. +``config.yaml``. The ``beets config -p`` command shows you where beets +expects its configuration file to be placed. You can start editing it +right away by running ``beets config -e``. This will create the file if +it not already exists and open it in your default text editor. It is also possible to customize the location of the configuration file and even use multiple layers of configuration. See `Configuration Location`_, diff --git a/test/_common.py b/test/_common.py index 540397cce..a1d1c8d44 100644 --- a/test/_common.py +++ b/test/_common.py @@ -272,3 +272,13 @@ def platform_posix(): yield finally: os.path = old_path + +@contextmanager +def system_mock(name): + import platform + old_system = platform.system + platform.system = lambda: name + try: + yield + finally: + platform.system = old_system diff --git a/test/test_config_command.py b/test/test_config_command.py new file mode 100644 index 000000000..5ef285dcd --- /dev/null +++ b/test/test_config_command.py @@ -0,0 +1,115 @@ +import os +import yaml + +from beets import ui +from beets import config + +import _common + + +class ConfigCommandTest(_common.TestCase): + + def setUp(self): + super(ConfigCommandTest, self).setUp() + self.io.install() + + if 'EDITOR' in os.environ: + del os.environ['EDITOR'] + + os.environ['BEETSDIR'] = self.temp_dir + self.config_path = os.path.join(self.temp_dir, 'config.yaml') + with open(self.config_path, 'w') as file: + file.write('library: lib\n') + file.write('option: value') + + self.cli_config_path = os.path.join(self.temp_dir, 'cli_config.yaml') + with open(self.cli_config_path, 'w') as file: + file.write('option: cli overwrite') + + config.clear() + config._materialized = False + + def tearDown(self): + super(ConfigCommandTest, self).tearDown() + self.execlp_restore() + + def test_show_user_config(self): + ui._raw_main(['config']) + output = yaml.load(self.io.getoutput()) + self.assertEqual(output['option'], 'value') + + def test_show_user_config_with_defaults(self): + ui._raw_main(['config', '-d']) + output = yaml.load(self.io.getoutput()) + self.assertEqual(output['option'], 'value') + self.assertEqual(output['library'], 'lib') + self.assertEqual(output['import']['timid'], False) + + def test_show_user_config_with_cli(self): + ui._raw_main(['--config', self.cli_config_path, 'config']) + output = yaml.load(self.io.getoutput()) + self.assertEqual(output['library'], 'lib') + self.assertEqual(output['option'], 'cli overwrite') + + def test_config_paths(self): + ui._raw_main(['config', '-p']) + paths = self.io.getoutput().split('\n') + self.assertEqual(len(paths), 2) + self.assertEqual(paths[0], self.config_path) + + def test_config_paths_with_cli(self): + ui._raw_main(['--config', self.cli_config_path, 'config', '-p']) + paths = self.io.getoutput().split('\n') + self.assertEqual(len(paths), 3) + self.assertEqual(paths[0], self.cli_config_path) + + def test_edit_config_with_editor_env(self): + self.execlp_stub() + os.environ['EDITOR'] = 'myeditor' + + ui._raw_main(['config', '-e']) + self.assertEqual(self._execlp_call, ['myeditor', self.config_path]) + + def test_edit_config_with_open(self): + self.execlp_stub() + + with _common.system_mock('Darwin'): + ui._raw_main(['config', '-e']) + self.assertEqual(self._execlp_call, ['open', '-n', self.config_path]) + + + def test_edit_config_with_xdg_open(self): + self.execlp_stub() + + with _common.system_mock('Linux'): + ui._raw_main(['config', '-e']) + self.assertEqual(self._execlp_call, ['xdg-open', self.config_path]) + + def test_edit_config_with_windows_exec(self): + self.execlp_stub() + + with _common.system_mock('Windows'): + ui._raw_main(['config', '-e']) + self.assertEqual(self._execlp_call, [self.config_path]) + + def test_config_editor_not_found(self): + def raise_os_error(*args): + raise OSError + os.execlp = raise_os_error + with self.assertRaises(ui.UserError) as user_error: + ui._raw_main(['config', '-e']) + self.assertIn('Could not edit configuration', + str(user_error.exception.args[0])) + + + def execlp_stub(self): + self._execlp_call = None + def _execlp_stub(file, *args): + self._execlp_call = [file] + list(args[1:]) + + self._orig_execlp = os.execlp + os.execlp = _execlp_stub + + def execlp_restore(self): + if hasattr(self, '_orig_execlp'): + os.execlp = self._orig_execlp