diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 5e88e9bee..68efb16b7 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -1250,6 +1250,8 @@ write_cmd.func = write_func default_commands.append(write_cmd) +# config: Show and edit user configuration. + 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') @@ -1257,25 +1259,19 @@ 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] - + # Print paths. if opts.paths: for source in config.sources: + if not opts.defaults and source.default: + continue if source.filename: print(source.filename) + + # Open in editor. elif opts.edit: path = config.user_config_path() @@ -1297,9 +1293,10 @@ def config_func(lib, opts, args): except OSError: raise ui.UserError("Could not edit configuration. Please" "set the EDITOR environment variable.") + + # Dump configuration. else: - config_dict = _config_get(config) - print(yaml.safe_dump(config_dict, default_flow_style=False)) + print(config.dump(full=opts.defaults)) config_cmd.func = config_func default_commands.append(config_cmd) diff --git a/beets/util/confit.py b/beets/util/confit.py index 11ec08620..1e89c769c 100644 --- a/beets/util/confit.py +++ b/beets/util/confit.py @@ -402,6 +402,20 @@ class ConfigView(object): 'a list'.format(self.name) ) + def flatten(self): + """Create a hierarchy of OrderedDicts containing the data from + this view, recursively reifying all views to get their + represented values. + """ + od = OrderedDict() + for key, view in self.items(): + try: + od[key] = view.flatten() + except ConfigTypeError: + od[key] = view.get() + return od + + class RootView(ConfigView): """The base of a view hierarchy. This view keeps track of the sources that may be accessed by subviews. @@ -539,7 +553,7 @@ def config_dirs(): return out -# YAML. +# YAML loading. class Loader(yaml.SafeLoader): """A customized YAML loader. This loader deviates from the official @@ -606,6 +620,101 @@ def load_yaml(filename): raise ConfigReadError(filename, exc) +# YAML dumping. + +class Dumper(yaml.SafeDumper): + """A PyYAML Dumper that represents OrderedDicts as ordinary mappings + (in order, of course). + """ + # From http://pyyaml.org/attachment/ticket/161/use_ordered_dict.py + def represent_mapping(self, tag, mapping, flow_style=None): + value = [] + node = yaml.MappingNode(tag, value, flow_style=flow_style) + if self.alias_key is not None: + self.represented_objects[self.alias_key] = node + best_style = False + if hasattr(mapping, 'items'): + mapping = list(mapping.items()) + for item_key, item_value in mapping: + node_key = self.represent_data(item_key) + node_value = self.represent_data(item_value) + if not (isinstance(node_key, yaml.ScalarNode) + and not node_key.style): + best_style = False + if not (isinstance(node_value, yaml.ScalarNode) + and not node_value.style): + best_style = False + value.append((node_key, node_value)) + if flow_style is None: + if self.default_flow_style is not None: + node.flow_style = self.default_flow_style + else: + node.flow_style = best_style + return node + + def represent_list(self, data): + """If a list has less than 4 items, represent it in inline style + (i.e. comma separated, within square brackets). + """ + node = super(Dumper, self).represent_list(data) + length = len(data) + if self.default_flow_style is None and length < 4: + node.flow_style = True + elif self.default_flow_style is None: + node.flow_style = False + return node + + def represent_bool(self, data): + """Represent bool as 'yes' or 'no' instead of 'true' or 'false'. + """ + if data: + value = 'yes' + else: + value = 'no' + return self.represent_scalar('tag:yaml.org,2002:bool', value) + + def represent_none(self, data): + """Represent a None value with nothing instead of 'none'. + """ + return self.represent_scalar('tag:yaml.org,2002:null', '') + +Dumper.add_representer(OrderedDict, Dumper.represent_dict) +Dumper.add_representer(bool, Dumper.represent_bool) +Dumper.add_representer(type(None), Dumper.represent_none) +Dumper.add_representer(list, Dumper.represent_list) + +def restore_yaml_comments(data, default_data): + """Scan default_data for comments (we include empty lines in our + definition of comments) and place them before the same keys in data. + Only works with comments that are on one or more own lines, i.e. + not next to a yaml mapping. + """ + comment_map = dict() + default_lines = iter(default_data.splitlines()) + for line in default_lines: + if not line: + comment = "\n" + elif line.startswith("#"): + comment = "{0}\n".format(line) + else: + continue + while True: + line = next(default_lines) + if line and not line.startswith("#"): + break + comment += "{0}\n".format(line) + key = line.split(':')[0].strip() + comment_map[key] = comment + out_lines = iter(data.splitlines()) + out_data = "" + for line in out_lines: + key = line.split(':')[0].strip() + if key in comment_map: + out_data += comment_map[key] + out_data += "{0}\n".format(line) + return out_data + + # Main interface. class Configuration(RootView): @@ -704,6 +813,53 @@ class Configuration(RootView): filename = os.path.abspath(filename) self.set(ConfigSource(load_yaml(filename), filename)) + def dump(self, filename=None, full=True): + """Dump the Configuration object to a YAML file. + + The order of the keys is determined from the default + configuration file. All keys not in the default configuration + will be appended to the end of the file. + + :param filename: The file to dump the configuration to, or None + if the YAML string should be returned instead + :type filename: unicode + :param full: Dump settings that don't differ from the defaults + as well + """ + out_dict = OrderedDict() + default_conf = next(x for x in self.sources if x.default) + try: + default_keys = list(default_conf.keys()) + except AttributeError: + default_keys = [] + new_keys = [x for x in self.keys() if not x in default_keys] + out_keys = default_keys + new_keys + for key in out_keys: + # Skip entries unchanged from default config + if (not full and key in default_keys + and self[key].get() == default_conf[key]): + continue + try: + out_dict[key] = self[key].flatten() + except ConfigTypeError: + out_dict[key] = self[key].get() + + yaml_out = yaml.dump(out_dict, Dumper=Dumper, + default_flow_style=None, indent=4, + width=1000) + + # Restore comments to the YAML text. + with open(default_conf.filename, 'r') as fp: + default_data = fp.read() + yaml_out = restore_yaml_comments(yaml_out, default_data) + + # Return the YAML or write it to a file. + if filename is None: + return yaml_out + else: + with open(filename, 'w') as fp: + fp.write(yaml_out) + class LazyConfig(Configuration): """A Configuration at reads files on demand when it is first