Explorar el Código

Support setting whole lists and dicts from the command-line (#303).

Dan Helfman hace 2 meses
padre
commit
eca78fbc2c

+ 30 - 15
borgmatic/commands/arguments.py

@@ -1,11 +1,16 @@
 import collections
 import collections
+import decimal
 import itertools
 import itertools
+import io
 import json
 import json
 import re
 import re
 import sys
 import sys
 from argparse import ArgumentParser
 from argparse import ArgumentParser
 
 
 from borgmatic.config import collect
 from borgmatic.config import collect
+import borgmatic.config.schema
+
+import ruamel.yaml
 
 
 ACTION_ALIASES = {
 ACTION_ALIASES = {
     'repo-create': ['rcreate', 'init', '-I'],
     'repo-create': ['rcreate', 'init', '-I'],
@@ -308,8 +313,9 @@ def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names
 
 
         --foo.bar
         --foo.bar
 
 
-    If "foo" is instead an array of objects, it will get added like this
+    If "foo" is instead an array of objects, both of the following will get added:
 
 
+        --foo
         --foo[0].bar
         --foo[0].bar
 
 
     And if names are also passed in, they are considered to be the name components of an option
     And if names are also passed in, they are considered to be the name components of an option
@@ -328,12 +334,8 @@ def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names
             raise ValueError(f'Unknown type in configuration schema: {schema_type}')
             raise ValueError(f'Unknown type in configuration schema: {schema_type}')
 
 
     # If this is an "object" type, recurse for each child option ("property").
     # 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 schema_type == 'object':
+        properties = schema.get('properties')
 
 
         if properties:
         if properties:
             for name, child in properties.items():
             for name, child in properties.items():
@@ -341,23 +343,37 @@ def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names
                     arguments_group,
                     arguments_group,
                     child,
                     child,
                     unparsed_arguments,
                     unparsed_arguments,
-                    names + ((name + '[0]',) if child.get('type') == 'array' else (name,)),
+                    names + (name,)
                 )
                 )
 
 
         return
         return
 
 
+    # If this is an "array" type, recurse for each child option of its items type. Don't return yet,
+    # so that a flag also gets added below for the array itself.
+    if schema_type == 'array':
+        properties = borgmatic.config.schema.get_properties(schema.get('items', {}))
+
+        if properties:
+            for name, child in properties.items():
+                add_arguments_from_schema(
+                    arguments_group,
+                    child,
+                    unparsed_arguments,
+                    names[:-1] + (f'{names[-1]}[0]',) + (name,)
+                )
+
     flag_name = '.'.join(names)
     flag_name = '.'.join(names)
     description = schema.get('description')
     description = schema.get('description')
     metavar = names[-1].upper()
     metavar = names[-1].upper()
 
 
-    if schema_type == 'array':
-        metavar = metavar.rstrip('S')
-
     if description:
     if description:
         if schema_type == 'array':
         if schema_type == 'array':
-            items_schema = schema.get('items', {})
+            example_buffer = io.StringIO()
+            yaml = ruamel.yaml.YAML(typ='safe')
+            yaml.default_flow_style = True
+            yaml.dump(schema.get('example'), example_buffer)
 
 
-            description += ' Can specify flag multiple times.'
+            description += f' Example value: "{example_buffer.getvalue().strip()}"'
 
 
         if '[0]' in flag_name:
         if '[0]' in flag_name:
             description += ' To specify a different list element, replace the "[0]" with another array index ("[1]", "[2]", etc.).'
             description += ' To specify a different list element, replace the "[0]" with another array index ("[1]", "[2]", etc.).'
@@ -365,7 +381,7 @@ def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names
         description = description.replace('%', '%%')
         description = description.replace('%', '%%')
 
 
     try:
     try:
-        argument_type = {'string': str, 'integer': int, 'boolean': bool, 'array': str}[schema_type]
+        argument_type = {'string': str, 'integer': int, 'number': decimal.Decimal, 'boolean': bool, 'array': str}[schema_type]
     except KeyError:
     except KeyError:
         raise ValueError(f'Unknown type in configuration schema: {schema_type}')
         raise ValueError(f'Unknown type in configuration schema: {schema_type}')
 
 
@@ -373,7 +389,6 @@ def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names
         f"--{flag_name.replace('_', '-')}",
         f"--{flag_name.replace('_', '-')}",
         type=argument_type,
         type=argument_type,
         metavar=metavar,
         metavar=metavar,
-        action='append' if schema_type == 'array' else None,
         help=description,
         help=description,
     )
     )
 
 

+ 19 - 3
borgmatic/config/arguments.py

@@ -21,7 +21,7 @@ def set_values(config, keys, value):
         ('mylist[0]', 'foo')
         ('mylist[0]', 'foo')
 
 
     This looks for the zeroth element of "mylist" in the given configuration. And within that value,
     This looks for the zeroth element of "mylist" in the given configuration. And within that value,
-    it looks up "foo" and sets it to the given value. Finally:
+    it looks up "foo" and sets it to the given value.
     '''
     '''
     if not keys:
     if not keys:
         return
         return
@@ -89,6 +89,22 @@ def type_for_option(schema, option_keys):
         return None
         return None
 
 
 
 
+def convert_value_type(value, option_type):
+    '''
+    Given a string value and its schema type as a string, determine its logical type (string,
+    boolean, integer, etc.), and return it converted to that type.
+
+    If the option type is a string, leave the value as a string so that special characters in it
+    don't get interpreted as YAML during conversion.
+
+    Raise ruamel.yaml.error.YAMLError if there's a parse issue with the YAML.
+    '''
+    if option_type == 'string':
+        return value
+
+    return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value))
+
+
 def prepare_arguments_for_config(global_arguments, schema):
 def prepare_arguments_for_config(global_arguments, schema):
     '''
     '''
     Given global arguments as an argparse.Namespace and a configuration schema dict, parse each
     Given global arguments as an argparse.Namespace and a configuration schema dict, parse each
@@ -125,11 +141,11 @@ def prepare_arguments_for_config(global_arguments, schema):
             prepared_values.append(
             prepared_values.append(
                 (
                 (
                     keys,
                     keys,
-                    value,
+                    convert_value_type(value, option_type),
                 )
                 )
             )
             )
         except ruamel.yaml.error.YAMLError as error:
         except ruamel.yaml.error.YAMLError as error:
-            raise ValueError(f"Invalid override '{raw_override}': {error.problem}")
+            raise ValueError(f'Invalid override "{argument_name}": {error.problem}')
 
 
     return tuple(prepared_values)
     return tuple(prepared_values)
 
 

+ 4 - 25
borgmatic/config/generate.py

@@ -1,12 +1,12 @@
 import collections
 import collections
 import io
 import io
-import itertools
 import os
 import os
 import re
 import re
 
 
 import ruamel.yaml
 import ruamel.yaml
 
 
 from borgmatic.config import load, normalize
 from borgmatic.config import load, normalize
+import borgmatic.config.schema
 
 
 INDENT = 4
 INDENT = 4
 SEQUENCE_INDENT = 2
 SEQUENCE_INDENT = 2
@@ -22,27 +22,6 @@ def insert_newline_before_comment(config, field_name):
     )
     )
 
 
 
 
-def get_properties(schema):
-    '''
-    Given a schema dict, return its properties. But if it's got sub-schemas with multiple different
-    potential properties, returned their merged properties instead (interleaved so the first
-    properties of each sub-schema come first). The idea is that the user should see all possible
-    options even if they're not all possible together.
-    '''
-    if 'oneOf' in schema:
-        return dict(
-            item
-            for item in itertools.chain(
-                *itertools.zip_longest(
-                    *[sub_schema['properties'].items() for sub_schema in schema['oneOf']]
-                )
-            )
-            if item is not None
-        )
-
-    return schema['properties']
-
-
 def schema_to_sample_configuration(schema, source_config=None, level=0, parent_is_sequence=False):
 def schema_to_sample_configuration(schema, source_config=None, level=0, parent_is_sequence=False):
     '''
     '''
     Given a loaded configuration schema and a source configuration, generate and return sample
     Given a loaded configuration schema and a source configuration, generate and return sample
@@ -78,7 +57,7 @@ def schema_to_sample_configuration(schema, source_config=None, level=0, parent_i
                         sub_schema, (source_config or {}).get(field_name, {}), level + 1
                         sub_schema, (source_config or {}).get(field_name, {}), level + 1
                     ),
                     ),
                 )
                 )
-                for field_name, sub_schema in get_properties(schema).items()
+                for field_name, sub_schema in borgmatic.config.schema.get_properties(schema).items()
             ]
             ]
         )
         )
         indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
         indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
@@ -189,7 +168,7 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
         return
         return
 
 
     for field_name in config[0].keys():
     for field_name in config[0].keys():
-        field_schema = get_properties(schema['items']).get(field_name, {})
+        field_schema = borgmatic.config.schema.get_properties(schema['items']).get(field_name, {})
         description = field_schema.get('description')
         description = field_schema.get('description')
 
 
         # No description to use? Skip it.
         # No description to use? Skip it.
@@ -223,7 +202,7 @@ def add_comments_to_configuration_object(
         if skip_first and index == 0:
         if skip_first and index == 0:
             continue
             continue
 
 
-        field_schema = get_properties(schema).get(field_name, {})
+        field_schema = borgmatic.config.schema.get_properties(schema).get(field_name, {})
         description = field_schema.get('description', '').strip()
         description = field_schema.get('description', '').strip()
 
 
         # If this isn't a default key, add an indicator to the comment flagging it to be commented
         # If this isn't a default key, add an indicator to the comment flagging it to be commented

+ 5 - 1
borgmatic/config/normalize.py

@@ -326,7 +326,11 @@ def normalize(config_filename, config):
         config['repositories'] = []
         config['repositories'] = []
 
 
         for repository_dict in repositories:
         for repository_dict in repositories:
-            repository_path = repository_dict['path']
+            repository_path = repository_dict.get('path')
+
+            if repository_path is None:
+                continue
+
             if '~' in repository_path:
             if '~' in repository_path:
                 logs.append(
                 logs.append(
                     logging.makeLogRecord(
                     logging.makeLogRecord(

+ 22 - 0
borgmatic/config/schema.py

@@ -0,0 +1,22 @@
+import itertools
+
+
+def get_properties(schema):
+    '''
+    Given a schema dict, return its properties. But if it's got sub-schemas with multiple different
+    potential properties, returned their merged properties instead (interleaved so the first
+    properties of each sub-schema come first). The idea is that the user should see all possible
+    options even if they're not all possible together.
+    '''
+    if 'oneOf' in schema:
+        return dict(
+            item
+            for item in itertools.chain(
+                *itertools.zip_longest(
+                    *[sub_schema['properties'].items() for sub_schema in schema['oneOf']]
+                )
+            )
+            if item is not None
+        )
+
+    return schema.get('properties', {})

+ 34 - 2
borgmatic/config/schema.yaml

@@ -53,8 +53,7 @@ properties:
             output of "borg help placeholders" for details. See ssh_command for
             output of "borg help placeholders" for details. See ssh_command for
             SSH options like identity file or port. If systemd service is used,
             SSH options like identity file or port. If systemd service is used,
             then add local repository paths in the systemd service file to the
             then add local repository paths in the systemd service file to the
-            ReadWritePaths list. Prior to borgmatic 1.7.10, repositories was a
-            list of plain path strings.
+            ReadWritePaths list.
         example:
         example:
             - path: ssh://user@backupserver/./sourcehostname.borg
             - path: ssh://user@backupserver/./sourcehostname.borg
               label: backupserver
               label: backupserver
@@ -738,6 +737,10 @@ properties:
             List of one or more consistency checks to run on a periodic basis
             List of one or more consistency checks to run on a periodic basis
             (if "frequency" is set) or every time borgmatic runs checks (if
             (if "frequency" is set) or every time borgmatic runs checks (if
             "frequency" is omitted).
             "frequency" is omitted).
+        example:
+          - name: archives
+            frequency: 2 weeks
+          - name: repository
     check_repositories:
     check_repositories:
         type: array
         type: array
         items:
         items:
@@ -1115,6 +1118,10 @@ properties:
             List of one or more command hooks to execute, triggered at
             List of one or more command hooks to execute, triggered at
             particular points during borgmatic's execution. For each command
             particular points during borgmatic's execution. For each command
             hook, specify one of "before" or "after", not both.
             hook, specify one of "before" or "after", not both.
+        example:
+            - before: action
+              when: [create]
+              run: [echo Backing up.]
     bootstrap:
     bootstrap:
         type: object
         type: object
         properties:
         properties:
@@ -1329,6 +1336,9 @@ properties:
             https://www.postgresql.org/docs/current/app-pgdump.html and
             https://www.postgresql.org/docs/current/app-pgdump.html and
             https://www.postgresql.org/docs/current/libpq-ssl.html for
             https://www.postgresql.org/docs/current/libpq-ssl.html for
             details.
             details.
+        example:
+            - name: users
+              hostname: database.example.org
     mariadb_databases:
     mariadb_databases:
         type: array
         type: array
         items:
         items:
@@ -1473,6 +1483,9 @@ properties:
             added to your source directories at runtime and streamed directly
             added to your source directories at runtime and streamed directly
             to Borg. Requires mariadb-dump/mariadb commands. See
             to Borg. Requires mariadb-dump/mariadb commands. See
             https://mariadb.com/kb/en/library/mysqldump/ for details.
             https://mariadb.com/kb/en/library/mysqldump/ for details.
+        example:
+            - name: users
+              hostname: database.example.org
     mysql_databases:
     mysql_databases:
         type: array
         type: array
         items:
         items:
@@ -1618,6 +1631,9 @@ properties:
             to Borg. Requires mysqldump/mysql commands. See
             to Borg. Requires mysqldump/mysql commands. See
             https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html for
             https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html for
             details.
             details.
+        example:
+            - name: users
+              hostname: database.example.org
     sqlite_databases:
     sqlite_databases:
         type: array
         type: array
         items:
         items:
@@ -1647,6 +1663,15 @@ properties:
                         Path to the SQLite database file to restore to. Defaults
                         Path to the SQLite database file to restore to. Defaults
                         to the "path" option.
                         to the "path" option.
                     example: /var/lib/sqlite/users.db
                     example: /var/lib/sqlite/users.db
+        description: |
+            List of one or more SQLite databases to dump before creating a
+            backup, run once per configuration file. The database dumps are
+            added to your source directories at runtime and streamed directly to
+            Borg. Requires the sqlite3 command. See https://sqlite.org/cli.html
+            for details.
+        example:
+            - name: users
+              path: /var/lib/db.sqlite
     mongodb_databases:
     mongodb_databases:
         type: array
         type: array
         items:
         items:
@@ -1749,6 +1774,9 @@ properties:
             to Borg. Requires mongodump/mongorestore commands. See
             to Borg. Requires mongodump/mongorestore commands. See
             https://docs.mongodb.com/database-tools/mongodump/ and
             https://docs.mongodb.com/database-tools/mongodump/ and
             https://docs.mongodb.com/database-tools/mongorestore/ for details.
             https://docs.mongodb.com/database-tools/mongorestore/ for details.
+        example:
+            - name: users
+              hostname: database.example.org
     ntfy:
     ntfy:
         type: object
         type: object
         required: ['topic']
         required: ['topic']
@@ -2231,9 +2259,13 @@ properties:
                     properties:
                     properties:
                         url:
                         url:
                             type: string
                             type: string
+                            description: URL of this Apprise service.
                             example: "gotify://hostname/token"
                             example: "gotify://hostname/token"
                         label:
                         label:
                             type: string
                             type: string
+                            description: |
+                                Label used in borgmatic logs for this Apprise
+                                service.
                             example: gotify
                             example: gotify
                 description: |
                 description: |
                     A list of Apprise services to publish to with URLs and
                     A list of Apprise services to publish to with URLs and

+ 0 - 76
tests/unit/config/test_generate.py

@@ -4,82 +4,6 @@ from flexmock import flexmock
 from borgmatic.config import generate as module
 from borgmatic.config import generate as module
 
 
 
 
-def test_get_properties_with_simple_object():
-    schema = {
-        'type': 'object',
-        'properties': dict(
-            [
-                ('field1', {'example': 'Example'}),
-            ]
-        ),
-    }
-
-    assert module.get_properties(schema) == schema['properties']
-
-
-def test_get_properties_merges_oneof_list_properties():
-    schema = {
-        'type': 'object',
-        'oneOf': [
-            {
-                'properties': dict(
-                    [
-                        ('field1', {'example': 'Example 1'}),
-                        ('field2', {'example': 'Example 2'}),
-                    ]
-                ),
-            },
-            {
-                'properties': dict(
-                    [
-                        ('field2', {'example': 'Example 2'}),
-                        ('field3', {'example': 'Example 3'}),
-                    ]
-                ),
-            },
-        ],
-    }
-
-    assert module.get_properties(schema) == dict(
-        schema['oneOf'][0]['properties'], **schema['oneOf'][1]['properties']
-    )
-
-
-def test_get_properties_interleaves_oneof_list_properties():
-    schema = {
-        'type': 'object',
-        'oneOf': [
-            {
-                'properties': dict(
-                    [
-                        ('field1', {'example': 'Example 1'}),
-                        ('field2', {'example': 'Example 2'}),
-                        ('field3', {'example': 'Example 3'}),
-                    ]
-                ),
-            },
-            {
-                'properties': dict(
-                    [
-                        ('field4', {'example': 'Example 4'}),
-                        ('field5', {'example': 'Example 5'}),
-                    ]
-                ),
-            },
-        ],
-    }
-
-    assert module.get_properties(schema) == dict(
-        [
-            ('field1', {'example': 'Example 1'}),
-            ('field4', {'example': 'Example 4'}),
-            ('field2', {'example': 'Example 2'}),
-            ('field5', {'example': 'Example 5'}),
-            ('field3', {'example': 'Example 3'}),
-        ]
-    )
-
-
 def test_schema_to_sample_configuration_generates_config_map_with_examples():
 def test_schema_to_sample_configuration_generates_config_map_with_examples():
     schema = {
     schema = {
         'type': 'object',
         'type': 'object',

+ 80 - 0
tests/unit/config/test_schema.py

@@ -0,0 +1,80 @@
+from borgmatic.config import schema as module
+
+
+def test_get_properties_with_simple_object():
+    schema = {
+        'type': 'object',
+        'properties': dict(
+            [
+                ('field1', {'example': 'Example'}),
+            ]
+        ),
+    }
+
+    assert module.get_properties(schema) == schema['properties']
+
+
+def test_get_properties_merges_oneof_list_properties():
+    schema = {
+        'type': 'object',
+        'oneOf': [
+            {
+                'properties': dict(
+                    [
+                        ('field1', {'example': 'Example 1'}),
+                        ('field2', {'example': 'Example 2'}),
+                    ]
+                ),
+            },
+            {
+                'properties': dict(
+                    [
+                        ('field2', {'example': 'Example 2'}),
+                        ('field3', {'example': 'Example 3'}),
+                    ]
+                ),
+            },
+        ],
+    }
+
+    assert module.get_properties(schema) == dict(
+        schema['oneOf'][0]['properties'], **schema['oneOf'][1]['properties']
+    )
+
+
+def test_get_properties_interleaves_oneof_list_properties():
+    schema = {
+        'type': 'object',
+        'oneOf': [
+            {
+                'properties': dict(
+                    [
+                        ('field1', {'example': 'Example 1'}),
+                        ('field2', {'example': 'Example 2'}),
+                        ('field3', {'example': 'Example 3'}),
+                    ]
+                ),
+            },
+            {
+                'properties': dict(
+                    [
+                        ('field4', {'example': 'Example 4'}),
+                        ('field5', {'example': 'Example 5'}),
+                    ]
+                ),
+            },
+        ],
+    }
+
+    assert module.get_properties(schema) == dict(
+        [
+            ('field1', {'example': 'Example 1'}),
+            ('field4', {'example': 'Example 4'}),
+            ('field2', {'example': 'Example 2'}),
+            ('field5', {'example': 'Example 5'}),
+            ('field3', {'example': 'Example 3'}),
+        ]
+    )
+
+
+