Browse Source

Initial work on command-line flags for all configuration (#303).

Dan Helfman 3 months ago
parent
commit
92279d3c71

+ 158 - 9
borgmatic/commands/arguments.py

@@ -1,5 +1,7 @@
 import collections
 import collections
 import itertools
 import itertools
+import json
+import re
 import sys
 import sys
 from argparse import ArgumentParser
 from argparse import ArgumentParser
 
 
@@ -282,12 +284,155 @@ def parse_arguments_for_actions(unparsed_arguments, action_parsers, global_parse
     )
     )
 
 
 
 
-def make_parsers():
+def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names=None):
     '''
     '''
-    Build a global arguments parser, individual action parsers, and a combined parser containing
-    both. Return them as a tuple. The global parser is useful for parsing just global arguments
-    while ignoring actions, and the combined parser is handy for displaying help that includes
-    everything: global flags, a list of actions, etc.
+    Given an argparse._ArgumentGroup instance, a configuration schema dict, and a sequence of
+    unparsed argument strings, convert the entire schema into corresponding command-line flags and
+    add them to the arguments group.
+
+    For instance, given a schema of:
+
+        {
+            'type': 'object',
+            'properties': {
+                'foo': {
+                    'type': 'object',
+                    'properties': {
+                        'bar': {'type': 'integer'}
+                    }
+                }
+            }
+        }
+
+    ... the following flag will be added to the arguments group:
+
+        --foo.bar
+
+    If "foo" is instead an array of objects, it will get added like this
+
+        --foo[0].bar
+
+    And if names are also passed in, they are considered to be the name components of an option
+    (e.g. "foo" and "bar") and are used to construct a resulting flag.
+    '''
+    if names is None:
+        names = ()
+
+    schema_type = schema.get('type')
+
+    # If this option has multiple types, just use the first one (that isn't "null").
+    if isinstance(schema_type, list):
+        try:
+            schema_type = next(single_type for single_type in schema_type if single_type != 'null')
+        except StopIteration:
+            raise ValueError(f'Unknown type in configuration schema: {schema_type}')
+
+    # If this is an "object" type, recurse for each child option ("property").
+    if schema_type in {'object', 'array'}:
+        properties = (
+            schema.get('items', {}).get('properties')
+            if schema_type == 'array'
+            else schema.get('properties')
+        )
+
+        if properties:
+            for name, child in properties.items():
+                add_arguments_from_schema(
+                    arguments_group,
+                    child,
+                    unparsed_arguments,
+                    names + ((name + '[0]',) if child.get('type') == 'array' else (name,)),
+                )
+
+        return
+
+    flag_name = '.'.join(names)
+    description = schema.get('description')
+    metavar = names[-1].upper()
+
+    if schema_type == 'array':
+        metavar = metavar.rstrip('S')
+
+    if description:
+        if schema_type == 'array':
+            items_schema = schema.get('items', {})
+
+            description += ' Can specify flag multiple times.'
+
+        if '[0]' in flag_name:
+            description += ' To specify a different list element, replace the "[0]" with another array index ("[1]", "[2]", etc.).'
+
+        description = description.replace('%', '%%')
+
+    try:
+        argument_type = {'string': str, 'integer': int, 'boolean': bool, 'array': str}[schema_type]
+    except KeyError:
+        raise ValueError(f'Unknown type in configuration schema: {schema_type}')
+
+    arguments_group.add_argument(
+        f"--{flag_name.replace('_', '-')}",
+        type=argument_type,
+        metavar=metavar,
+        action='append' if schema_type == 'array' else None,
+        help=description,
+    )
+
+    # We want to support flags that can have arbitrary indices like:
+    #
+    #   --foo.bar[1].baz
+    #
+    # But argparse doesn't support that natively because the index can be an arbitrary number. We
+    # won't let that stop us though, will we? So, if the current flag name has an array component in
+    # it (e.g. a name with "[0]"), then make a pattern that would match the flag name regardless of
+    # the number that's in it. The idea is that we want to look for unparsed arguments that appear
+    # like the flag name, but instead of "[0]" they have, say, "[1]" or "[123]".
+    #
+    # Next, we check each unparsed argument against that pattern. If one of them matches, add an
+    # argument flag for it to the argument parser group. Example:
+    #
+    # Let's say flag_name is:
+    #
+    #     --foo.bar[0].baz
+    #
+    # ... then the regular expression pattern will be:
+    #
+    #     ^--foo\.bar\[\d+\]\.baz
+    #
+    # ... and, if that matches an unparsed argument of:
+    #
+    #     --foo.bar[1].baz
+    #
+    # ... then an argument flag will get added equal to that unparsed argument. And the unparsed
+    # argument will match it when parsing is performed! In this manner, we're using the actual user
+    # CLI input to inform what exact flags we support!
+    if '[0]' not in flag_name or '--help' in unparsed_arguments:
+        return
+
+    pattern = re.compile(f'^--{flag_name.replace("[0]", r"\[\d+\]").replace(".", r"\.")}$')
+    existing_flags = set(
+        itertools.chain(
+            *(group_action.option_strings for group_action in arguments_group._group_actions)
+        )
+    )
+
+    for unparsed in unparsed_arguments:
+        unparsed_flag_name = unparsed.split('=', 1)[0]
+
+        if pattern.match(unparsed_flag_name) and unparsed_flag_name not in existing_flags:
+            arguments_group.add_argument(
+                unparsed_flag_name,
+                type=argument_type,
+                metavar=metavar,
+                help=description,
+            )
+
+
+def make_parsers(schema, unparsed_arguments):
+    '''
+    Given a configuration schema dict, build a global arguments parser, individual action parsers,
+    and a combined parser containing both. Return them as a tuple. The global parser is useful for
+    parsing just global arguments while ignoring actions, and the combined parser is handy for
+    displaying help that includes everything: global flags, a list of actions, etc.
     '''
     '''
     config_paths = collect.get_default_config_paths(expand_home=True)
     config_paths = collect.get_default_config_paths(expand_home=True)
     unexpanded_config_paths = collect.get_default_config_paths(expand_home=False)
     unexpanded_config_paths = collect.get_default_config_paths(expand_home=False)
@@ -388,6 +533,7 @@ def make_parsers():
         action='store_true',
         action='store_true',
         help='Display installed version number of borgmatic and exit',
         help='Display installed version number of borgmatic and exit',
     )
     )
+    add_arguments_from_schema(global_group, schema, unparsed_arguments)
 
 
     global_plus_action_parser = ArgumentParser(
     global_plus_action_parser = ArgumentParser(
         description='''
         description='''
@@ -1523,15 +1669,18 @@ def make_parsers():
     return global_parser, action_parsers, global_plus_action_parser
     return global_parser, action_parsers, global_plus_action_parser
 
 
 
 
-def parse_arguments(*unparsed_arguments):
+def parse_arguments(schema, *unparsed_arguments):
     '''
     '''
-    Given command-line arguments with which this script was invoked, parse the arguments and return
-    them as a dict mapping from action name (or "global") to an argparse.Namespace instance.
+    Given a configuration schema dict and the command-line arguments with which this script was
+    invoked, parse the arguments and return them as a dict mapping from action name (or "global") to
+    an argparse.Namespace instance.
 
 
     Raise ValueError if the arguments cannot be parsed.
     Raise ValueError if the arguments cannot be parsed.
     Raise SystemExit with an error code of 0 if "--help" was requested.
     Raise SystemExit with an error code of 0 if "--help" was requested.
     '''
     '''
-    global_parser, action_parsers, global_plus_action_parser = make_parsers()
+    global_parser, action_parsers, global_plus_action_parser = make_parsers(
+        schema, unparsed_arguments
+    )
     arguments, remaining_action_arguments = parse_arguments_for_actions(
     arguments, remaining_action_arguments = parse_arguments_for_actions(
         unparsed_arguments, action_parsers.choices, global_parser
         unparsed_arguments, action_parsers.choices, global_parser
     )
     )

+ 21 - 9
borgmatic/commands/borgmatic.py

@@ -8,6 +8,8 @@ import time
 from queue import Queue
 from queue import Queue
 from subprocess import CalledProcessError
 from subprocess import CalledProcessError
 
 
+import ruamel.yaml
+
 import borgmatic.actions.borg
 import borgmatic.actions.borg
 import borgmatic.actions.break_lock
 import borgmatic.actions.break_lock
 import borgmatic.actions.change_passphrase
 import borgmatic.actions.change_passphrase
@@ -33,6 +35,7 @@ import borgmatic.actions.restore
 import borgmatic.actions.transfer
 import borgmatic.actions.transfer
 import borgmatic.commands.completion.bash
 import borgmatic.commands.completion.bash
 import borgmatic.commands.completion.fish
 import borgmatic.commands.completion.fish
+import borgmatic.config.load
 from borgmatic.borg import umount as borg_umount
 from borgmatic.borg import umount as borg_umount
 from borgmatic.borg import version as borg_version
 from borgmatic.borg import version as borg_version
 from borgmatic.commands.arguments import parse_arguments
 from borgmatic.commands.arguments import parse_arguments
@@ -570,14 +573,14 @@ def run_actions(
                     )
                     )
 
 
 
 
-def load_configurations(config_filenames, overrides=None, resolve_env=True):
+def load_configurations(config_filenames, global_arguments, overrides=None, resolve_env=True):
     '''
     '''
-    Given a sequence of configuration filenames, a sequence of configuration file override strings
-    in the form of "option.suboption=value", and whether to resolve environment variables, load and
-    validate each configuration file. Return the results as a tuple of: dict of configuration
-    filename to corresponding parsed configuration, a sequence of paths for all loaded configuration
-    files (including includes), and a sequence of logging.LogRecord instances containing any parse
-    errors.
+    Given a sequence of configuration filenames, global arguments as an argparse.Namespace, a
+    sequence of configuration file override strings in the form of "option.suboption=value", and
+    whether to resolve environment variables, load and validate each configuration file. Return the
+    results as a tuple of: dict of configuration filename to corresponding parsed configuration, a
+    sequence of paths for all loaded configuration files (including includes), and a sequence of
+    logging.LogRecord instances containing any parse errors.
 
 
     Log records are returned here instead of being logged directly because logging isn't yet
     Log records are returned here instead of being logged directly because logging isn't yet
     initialized at this point! (Although with the Delayed_logging_handler now in place, maybe this
     initialized at this point! (Although with the Delayed_logging_handler now in place, maybe this
@@ -605,6 +608,7 @@ def load_configurations(config_filenames, overrides=None, resolve_env=True):
             configs[config_filename], paths, parse_logs = validate.parse_configuration(
             configs[config_filename], paths, parse_logs = validate.parse_configuration(
                 config_filename,
                 config_filename,
                 validate.schema_filename(),
                 validate.schema_filename(),
+                global_arguments,
                 overrides,
                 overrides,
                 resolve_env,
                 resolve_env,
             )
             )
@@ -928,9 +932,17 @@ def exit_with_help_link():  # pragma: no cover
 def main(extra_summary_logs=[]):  # pragma: no cover
 def main(extra_summary_logs=[]):  # pragma: no cover
     configure_signals()
     configure_signals()
     configure_delayed_logging()
     configure_delayed_logging()
+    schema_filename = validate.schema_filename()
+
+    try:
+        schema = borgmatic.config.load.load_configuration(schema_filename)
+    except (ruamel.yaml.error.YAMLError, RecursionError) as error:
+        configure_logging(logging.CRITICAL)
+        logger.critical(error)
+        exit_with_help_link()
 
 
     try:
     try:
-        arguments = parse_arguments(*sys.argv[1:])
+        arguments = parse_arguments(schema, *sys.argv[1:])
     except ValueError as error:
     except ValueError as error:
         configure_logging(logging.CRITICAL)
         configure_logging(logging.CRITICAL)
         logger.critical(error)
         logger.critical(error)
@@ -953,10 +965,10 @@ def main(extra_summary_logs=[]):  # pragma: no cover
         print(borgmatic.commands.completion.fish.fish_completion())
         print(borgmatic.commands.completion.fish.fish_completion())
         sys.exit(0)
         sys.exit(0)
 
 
-    validate = bool('validate' in arguments)
     config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
     config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
     configs, config_paths, parse_logs = load_configurations(
     configs, config_paths, parse_logs = load_configurations(
         config_filenames,
         config_filenames,
+        global_arguments,
         global_arguments.overrides,
         global_arguments.overrides,
         resolve_env=global_arguments.resolve_env and not validate,
         resolve_env=global_arguments.resolve_env and not validate,
     )
     )

+ 137 - 0
borgmatic/config/arguments.py

@@ -0,0 +1,137 @@
+import io
+import re
+
+import ruamel.yaml
+
+
+LIST_INDEX_KEY_PATTERN = re.compile(r'^(?P<list_name>[a-zA-z-]+)\[(?P<index>\d+)\]$')
+
+
+def set_values(config, keys, value):
+    '''
+    Given a configuration dict, a sequence of parsed key strings, and a string value, descend into
+    the configuration hierarchy based on the keys to set the value into the right place.
+    '''
+    if not keys:
+        return
+
+    first_key = keys[0]
+
+    # Support "name[0]"-style list index syntax.
+    match = LIST_INDEX_KEY_PATTERN.match(first_key)
+
+    if match:
+        list_key = match.group('list_name')
+        list_index = int(match.group('index'))
+
+        if len(keys) == 1:
+            config[list_key][list_index] = value
+
+            return
+
+        if list_key not in config:
+            config[list_key] = []
+
+        try:
+            set_values(config[list_key][list_index], keys[1:], value)
+        except IndexError:
+            raise ValueError(f'The list index {first_key} is out of range')
+
+        return
+
+    if len(keys) == 1:
+        config[first_key] = value
+        return
+
+    if first_key not in config:
+        config[first_key] = {}
+
+    set_values(config[first_key], keys[1:], value)
+
+
+def type_for_option(schema, option_keys):
+    '''
+    Given a configuration schema dict and a sequence of keys identifying a potentially nested
+    option, e.g. ('extra_borg_options', 'create'), return the schema type of that option as a
+    string.
+
+    Return None if the option or its type cannot be found in the schema.
+    '''
+    option_schema = schema
+
+    for key in option_keys:
+        # Support "name[0]"-style list index syntax.
+        match = LIST_INDEX_KEY_PATTERN.match(key)
+
+        try:
+            if match:
+                option_schema = option_schema['properties'][match.group('list_name')]['items']
+            else:
+                option_schema = option_schema['properties'][key]
+        except KeyError:
+            return None
+
+    try:
+        return option_schema['type']
+    except KeyError:
+        return None
+
+
+def prepare_arguments_for_config(global_arguments, schema):
+    '''
+    Given global arguments as an argparse.Namespace and a configuration schema dict, parse each
+    argument that corresponds to an option in the schema and return a sequence of tuples (keys,
+    values) for that option, where keys is a sequence of strings. For instance, given the following
+    arguments:
+
+        argparse.Namespace(**{'my_option.sub_option': 'value1', 'other_option': 'value2'})
+
+    ... return this:
+
+        (
+            (('my_option', 'sub_option'), 'value1'),
+            (('other_option'), 'value2'),
+        )
+
+    Raise ValueError if an override can't be parsed.
+    '''
+    prepared_values = []
+
+    for argument_name, value in global_arguments.__dict__.items():
+        try:
+            if value is None:
+                continue
+
+            keys = tuple(argument_name.split('.'))
+            option_type = type_for_option(schema, keys)
+
+            # The argument doesn't correspond to any option in the schema, so ignore it. It's
+            # probably a flag that borgmatic has on the command-line but not in configuration.
+            if option_type is None:
+                continue
+
+            prepared_values.append(
+                (
+                    keys,
+                    value,
+                )
+            )
+        except ruamel.yaml.error.YAMLError as error:
+            raise ValueError(f"Invalid override '{raw_override}': {error.problem}")
+
+    return tuple(prepared_values)
+
+
+def apply_arguments_to_config(config, schema, global_arguments):
+    '''
+    Given a configuration dict, a corresponding configuration schema dict, and global arguments as
+    an argparse.Namespace, set those given argument values into their corresponding configuration
+    options in the configuration dict.
+
+    This supports argument flags of the from "--foo.bar.baz" where each dotted component is a nested
+    configuration object. Additionally, flags like "--foo.bar[0].baz" are supported to update a list
+    element in the configuration.
+    '''
+
+    for keys, value in prepare_arguments_for_config(global_arguments, schema):
+        set_values(config, keys, value)

+ 7 - 0
borgmatic/config/override.py

@@ -1,8 +1,12 @@
 import io
 import io
+import logging
 
 
 import ruamel.yaml
 import ruamel.yaml
 
 
 
 
+logger = logging.getLogger(__name__)
+
+
 def set_values(config, keys, value):
 def set_values(config, keys, value):
     '''
     '''
     Given a hierarchy of configuration dicts, a sequence of parsed key strings, and a string value,
     Given a hierarchy of configuration dicts, a sequence of parsed key strings, and a string value,
@@ -134,6 +138,9 @@ def apply_overrides(config, schema, raw_overrides):
     '''
     '''
     overrides = parse_overrides(raw_overrides, schema)
     overrides = parse_overrides(raw_overrides, schema)
 
 
+    if overrides:
+        logger.warning("The --override flag is deprecated and will be removed from a future release. Instead, use a command-line flag corresponding to the configuration option you'd like to set.")
+
     for keys, value in overrides:
     for keys, value in overrides:
         set_values(config, keys, value)
         set_values(config, keys, value)
         set_values(config, strip_section_names(keys), value)
         set_values(config, strip_section_names(keys), value)

+ 5 - 0
borgmatic/config/schema.yaml

@@ -36,9 +36,14 @@ properties:
             properties:
             properties:
                 path:
                 path:
                     type: string
                     type: string
+                    description: The local path or Borg URL of the repository.
                     example: ssh://user@backupserver/./{fqdn}
                     example: ssh://user@backupserver/./{fqdn}
                 label:
                 label:
                     type: string
                     type: string
+                    description: |
+                        An optional label for the repository, used in logging
+                        and to make selecting the repository easier on the
+                        command-line.
                     example: backupserver
                     example: backupserver
         description: |
         description: |
             A required list of local or remote repositories with paths and
             A required list of local or remote repositories with paths and

+ 9 - 6
borgmatic/config/validate.py

@@ -4,7 +4,7 @@ import os
 import jsonschema
 import jsonschema
 import ruamel.yaml
 import ruamel.yaml
 
 
-import borgmatic.config
+import borgmatic.config.arguments
 from borgmatic.config import constants, environment, load, normalize, override
 from borgmatic.config import constants, environment, load, normalize, override
 
 
 
 
@@ -84,13 +84,15 @@ def apply_logical_validation(config_filename, parsed_configuration):
             )
             )
 
 
 
 
-def parse_configuration(config_filename, schema_filename, overrides=None, resolve_env=True):
+def parse_configuration(config_filename, schema_filename, global_arguments, overrides=None, resolve_env=True):
     '''
     '''
     Given the path to a config filename in YAML format, the path to a schema filename in a YAML
     Given the path to a config filename in YAML format, the path to a schema filename in a YAML
-    rendition of JSON Schema format, a sequence of configuration file override strings in the form
-    of "option.suboption=value", and whether to resolve environment variables, return the parsed
-    configuration as a data structure of nested dicts and lists corresponding to the schema. Example
-    return value:
+    rendition of JSON Schema format, global arguments as an argparse.Namespace, a sequence of
+    configuration file override strings in the form of "option.suboption=value", and whether to
+    resolve environment variables, return the parsed configuration as a data structure of nested
+    dicts and lists corresponding to the schema. Example return value.
+
+    Example return value:
 
 
         {
         {
             'source_directories': ['/home', '/etc'],
             'source_directories': ['/home', '/etc'],
@@ -113,6 +115,7 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
     except (ruamel.yaml.error.YAMLError, RecursionError) as error:
     except (ruamel.yaml.error.YAMLError, RecursionError) as error:
         raise Validation_error(config_filename, (str(error),))
         raise Validation_error(config_filename, (str(error),))
 
 
+    borgmatic.config.arguments.apply_arguments_to_config(config, schema, global_arguments)
     override.apply_overrides(config, schema, overrides)
     override.apply_overrides(config, schema, overrides)
     constants.apply_constants(config, config.get('constants') if config else {})
     constants.apply_constants(config, config.get('constants') if config else {})