2
0
Эх сурвалжийг харах

Merge branch 'master' of https://github.com/diivi/borgmatic into feat/file-urls-support

Divyansh Singh 2 жил өмнө
parent
commit
e86d223bbf
48 өөрчлөгдсөн 922 нэмэгдсэн , 291 устгасан
  1. 1 0
      .flake8
  2. 18 1
      NEWS
  3. 2 2
      README.md
  4. 6 0
      borgmatic/actions/check.py
  5. 6 0
      borgmatic/actions/compact.py
  6. 6 0
      borgmatic/actions/create.py
  7. 6 0
      borgmatic/actions/prune.py
  8. 1 1
      borgmatic/borg/check.py
  9. 16 0
      borgmatic/borg/create.py
  10. 2 1
      borgmatic/borg/environment.py
  11. 7 0
      borgmatic/borg/extract.py
  12. 1 1
      borgmatic/borg/rlist.py
  13. 29 13
      borgmatic/commands/arguments.py
  14. 178 152
      borgmatic/commands/borgmatic.py
  15. 28 14
      borgmatic/config/schema.yaml
  16. 1 1
      borgmatic/config/validate.py
  17. 6 0
      borgmatic/hooks/cronhub.py
  18. 6 0
      borgmatic/hooks/cronitor.py
  19. 2 1
      borgmatic/hooks/healthchecks.py
  20. 1 0
      borgmatic/hooks/monitor.py
  21. 0 8
      borgmatic/hooks/ntfy.py
  22. 1 1
      docs/Dockerfile
  23. 4 1
      docs/how-to/backup-your-databases.md
  24. 38 22
      docs/how-to/deal-with-very-large-backups.md
  25. 1 1
      docs/how-to/develop-on-borgmatic.md
  26. 11 4
      docs/how-to/extract-a-backup.md
  27. 13 0
      docs/how-to/inspect-your-backups.md
  28. 6 6
      docs/how-to/monitor-your-backups.md
  29. 1 0
      docs/how-to/set-up-backups.md
  30. 2 0
      setup.cfg
  31. 1 1
      setup.py
  32. 1 0
      test_requirements.txt
  33. 0 7
      tests/integration/commands/test_arguments.py
  34. 66 3
      tests/unit/actions/test_check.py
  35. 60 3
      tests/unit/actions/test_compact.py
  36. 74 3
      tests/unit/actions/test_create.py
  37. 54 3
      tests/unit/actions/test_prune.py
  38. 25 2
      tests/unit/borg/test_create.py
  39. 5 0
      tests/unit/borg/test_environment.py
  40. 51 0
      tests/unit/borg/test_extract.py
  41. 22 0
      tests/unit/commands/test_arguments.py
  42. 78 16
      tests/unit/commands/test_borgmatic.py
  43. 8 0
      tests/unit/hooks/test_cronhub.py
  44. 8 0
      tests/unit/hooks/test_cronitor.py
  45. 17 0
      tests/unit/hooks/test_healthchecks.py
  46. 1 1
      tests/unit/hooks/test_mongodb.py
  47. 49 20
      tests/unit/hooks/test_ntfy.py
  48. 2 2
      tox.ini

+ 1 - 0
.flake8

@@ -0,0 +1 @@
+select = Q0

+ 18 - 1
NEWS

@@ -1,5 +1,22 @@
-1.7.9.dev0
+1.7.10.dev0
+ * #501: Optionally error if a source directory does not exist via "source_directories_must_exist"
+   option in borgmatic's location configuration.
+ * #618: Support for BORG_FILES_CACHE_TTL environment variable via "borg_files_cache_ttl" option in
+   borgmatic's storage configuration.
+
+1.7.9
  * #295: Add a SQLite database dump/restore hook.
+ * #304: Change the default action order when no actions are specified on the command-line to:
+   "create", "prune", "compact", "check". If you'd like to retain the old ordering ("prune" and
+   "compact" first), then specify actions explicitly on the command-line.
+ * #304: Run any command-line actions in the order specified instead of using a fixed ordering.
+ * #564: Add "--repository" flag to all actions where it makes sense, so you can run borgmatic on
+   a single configured repository instead of all of them.
+ * #628: Add a Healthchecks "log" state to send borgmatic logs to Healthchecks without signalling
+   success or failure.
+ * #647: Add "--strip-components all" feature on the "extract" action to remove leading path
+   components of files you extract. Must be used with the "--path" flag.
+ * Add support for Python 3.11.
 
 1.7.8
  * #620: With the "create" action and the "--list" ("--files") flag, only show excluded files at

+ 2 - 2
README.md

@@ -81,8 +81,8 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
 Your first step is to [install and configure
 borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/).
 
-For additional documentation, check out the links above for <a
-href="https://torsion.org/borgmatic/#documentation">borgmatic how-to and
+For additional documentation, check out the links above (left panel on wide screens)
+for <a href="https://torsion.org/borgmatic/#documentation">borgmatic how-to and
 reference guides</a>.
 
 

+ 6 - 0
borgmatic/actions/check.py

@@ -1,6 +1,7 @@
 import logging
 
 import borgmatic.borg.check
+import borgmatic.config.validate
 import borgmatic.hooks.command
 
 logger = logging.getLogger(__name__)
@@ -23,6 +24,11 @@ def run_check(
     '''
     Run the "check" action for the given repository.
     '''
+    if check_arguments.repository and not borgmatic.config.validate.repositories_match(
+        repository, check_arguments.repository
+    ):
+        return
+
     borgmatic.hooks.command.execute_hook(
         hooks.get('before_check'),
         hooks.get('umask'),

+ 6 - 0
borgmatic/actions/compact.py

@@ -2,6 +2,7 @@ import logging
 
 import borgmatic.borg.compact
 import borgmatic.borg.feature
+import borgmatic.config.validate
 import borgmatic.hooks.command
 
 logger = logging.getLogger(__name__)
@@ -24,6 +25,11 @@ def run_compact(
     '''
     Run the "compact" action for the given repository.
     '''
+    if compact_arguments.repository and not borgmatic.config.validate.repositories_match(
+        repository, compact_arguments.repository
+    ):
+        return
+
     borgmatic.hooks.command.execute_hook(
         hooks.get('before_compact'),
         hooks.get('umask'),

+ 6 - 0
borgmatic/actions/create.py

@@ -2,6 +2,7 @@ import json
 import logging
 
 import borgmatic.borg.create
+import borgmatic.config.validate
 import borgmatic.hooks.command
 import borgmatic.hooks.dispatch
 import borgmatic.hooks.dump
@@ -28,6 +29,11 @@ def run_create(
 
     If create_arguments.json is True, yield the JSON output from creating the archive.
     '''
+    if create_arguments.repository and not borgmatic.config.validate.repositories_match(
+        repository, create_arguments.repository
+    ):
+        return
+
     borgmatic.hooks.command.execute_hook(
         hooks.get('before_backup'),
         hooks.get('umask'),

+ 6 - 0
borgmatic/actions/prune.py

@@ -1,6 +1,7 @@
 import logging
 
 import borgmatic.borg.prune
+import borgmatic.config.validate
 import borgmatic.hooks.command
 
 logger = logging.getLogger(__name__)
@@ -23,6 +24,11 @@ def run_prune(
     '''
     Run the "prune" action for the given repository.
     '''
+    if prune_arguments.repository and not borgmatic.config.validate.repositories_match(
+        repository, prune_arguments.repository
+    ):
+        return
+
     borgmatic.hooks.command.execute_hook(
         hooks.get('before_prune'),
         hooks.get('umask'),

+ 1 - 1
borgmatic/borg/check.py

@@ -139,7 +139,7 @@ def filter_checks_on_frequency(
         if datetime.datetime.now() < check_time + frequency_delta:
             remaining = check_time + frequency_delta - datetime.datetime.now()
             logger.info(
-                f"Skipping {check} check due to configured frequency; {remaining} until next check"
+                f'Skipping {check} check due to configured frequency; {remaining} until next check'
             )
             filtered_checks.remove(check)
 

+ 16 - 0
borgmatic/borg/create.py

@@ -306,6 +306,20 @@ def collect_special_file_paths(
     )
 
 
+def check_all_source_directories_exist(source_directories):
+    '''
+    Given a sequence of source directories, check that they all exist. If any do not, raise an
+    exception.
+    '''
+    missing_directories = [
+        source_directory
+        for source_directory in source_directories
+        if not os.path.exists(source_directory)
+    ]
+    if missing_directories:
+        raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}")
+
+
 def create_archive(
     dry_run,
     repository,
@@ -331,6 +345,8 @@ def create_archive(
     borgmatic_source_directories = expand_directories(
         collect_borgmatic_source_directories(location_config.get('borgmatic_source_directory'))
     )
+    if location_config.get('source_directories_must_exist', False):
+        check_all_source_directories_exist(location_config.get('source_directories'))
     sources = deduplicate_directories(
         map_directories_to_devices(
             expand_directories(

+ 2 - 1
borgmatic/borg/environment.py

@@ -2,6 +2,7 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
     'borg_base_directory': 'BORG_BASE_DIR',
     'borg_config_directory': 'BORG_CONFIG_DIR',
     'borg_cache_directory': 'BORG_CACHE_DIR',
+    'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
     'borg_security_directory': 'BORG_SECURITY_DIR',
     'borg_keys_directory': 'BORG_KEYS_DIR',
     'encryption_passcommand': 'BORG_PASSCOMMAND',
@@ -27,7 +28,7 @@ def make_environment(storage_config):
         value = storage_config.get(option_name)
 
         if value:
-            environment[environment_variable_name] = value
+            environment[environment_variable_name] = str(value)
 
     for (
         option_name,

+ 7 - 0
borgmatic/borg/extract.py

@@ -87,6 +87,13 @@ def extract_archive(
     else:
         numeric_ids_flags = ('--numeric-owner',) if location_config.get('numeric_ids') else ()
 
+    if strip_components == 'all':
+        if not paths:
+            raise ValueError('The --strip-components flag with "all" requires at least one --path')
+
+        # Calculate the maximum number of leading path components of the given paths.
+        strip_components = max(0, *(len(path.split(os.path.sep)) - 1 for path in paths))
+
     full_command = (
         (local_path, 'extract')
         + (('--remote-path', remote_path) if remote_path else ())

+ 1 - 1
borgmatic/borg/rlist.py

@@ -17,7 +17,7 @@ def resolve_archive_name(
 
     Raise ValueError if "latest" is given but there are no archives in the repository.
     '''
-    if archive != "latest":
+    if archive != 'latest':
         return archive
 
     lock_wait = storage_config.get('lock_wait', None)

+ 29 - 13
borgmatic/commands/arguments.py

@@ -46,11 +46,12 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
     if 'borg' in unparsed_arguments:
         subparsers = {'borg': subparsers['borg']}
 
-    for subparser_name, subparser in subparsers.items():
-        if subparser_name not in remaining_arguments:
-            continue
+    for argument in remaining_arguments:
+        canonical_name = alias_to_subparser_name.get(argument, argument)
+        subparser = subparsers.get(canonical_name)
 
-        canonical_name = alias_to_subparser_name.get(subparser_name, subparser_name)
+        if not subparser:
+            continue
 
         # If a parsed value happens to be the same as the name of a subparser, remove it from the
         # remaining arguments. This prevents, for instance, "check --only extract" from triggering
@@ -67,9 +68,9 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
 
         arguments[canonical_name] = parsed
 
-    # If no actions are explicitly requested, assume defaults: prune, compact, create, and check.
+    # If no actions are explicitly requested, assume defaults.
     if not arguments and '--help' not in unparsed_arguments and '-h' not in unparsed_arguments:
-        for subparser_name in ('prune', 'compact', 'create', 'check'):
+        for subparser_name in ('create', 'prune', 'compact', 'check'):
             subparser = subparsers[subparser_name]
             parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments)
             arguments[subparser_name] = parsed
@@ -215,7 +216,7 @@ def make_parsers():
     top_level_parser = ArgumentParser(
         description='''
             Simple, configuration-driven backup software for servers and workstations. If none of
-            the action options are given, then borgmatic defaults to: prune, compact, create, and
+            the action options are given, then borgmatic defaults to: create, prune, compact, and
             check.
             ''',
         parents=[global_parser],
@@ -224,7 +225,7 @@ def make_parsers():
     subparsers = top_level_parser.add_subparsers(
         title='actions',
         metavar='',
-        help='Specify zero or more actions. Defaults to prune, compact, create, and check. Use --help with action for details:',
+        help='Specify zero or more actions. Defaults to creat, prune, compact, and check. Use --help with action for details:',
     )
     rcreate_parser = subparsers.add_parser(
         'rcreate',
@@ -332,6 +333,10 @@ def make_parsers():
         add_help=False,
     )
     prune_group = prune_parser.add_argument_group('prune arguments')
+    prune_group.add_argument(
+        '--repository',
+        help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file)',
+    )
     prune_group.add_argument(
         '--stats',
         dest='stats',
@@ -352,6 +357,10 @@ def make_parsers():
         add_help=False,
     )
     compact_group = compact_parser.add_argument_group('compact arguments')
+    compact_group.add_argument(
+        '--repository',
+        help='Path of specific existing repository to compact (must be already specified in a borgmatic configuration file)',
+    )
     compact_group.add_argument(
         '--progress',
         dest='progress',
@@ -384,6 +393,10 @@ def make_parsers():
         add_help=False,
     )
     create_group = create_parser.add_argument_group('create arguments')
+    create_group.add_argument(
+        '--repository',
+        help='Path of specific existing repository to backup to (must be already specified in a borgmatic configuration file)',
+    )
     create_group.add_argument(
         '--progress',
         dest='progress',
@@ -414,6 +427,10 @@ def make_parsers():
         add_help=False,
     )
     check_group = check_parser.add_argument_group('check arguments')
+    check_group.add_argument(
+        '--repository',
+        help='Path of specific existing repository to check (must be already specified in a borgmatic configuration file)',
+    )
     check_group.add_argument(
         '--progress',
         dest='progress',
@@ -475,10 +492,9 @@ def make_parsers():
     )
     extract_group.add_argument(
         '--strip-components',
-        type=int,
+        type=lambda number: number if number == 'all' else int(number),
         metavar='NUMBER',
-        dest='strip_components',
-        help='Number of leading path components to remove from each extracted path. Skip paths with fewer elements',
+        help='Number of leading path components to remove from each extracted path or "all" to strip all leading path components. Skip paths with fewer elements',
     )
     extract_group.add_argument(
         '--progress',
@@ -611,7 +627,7 @@ def make_parsers():
         metavar='NAME',
         nargs='+',
         dest='databases',
-        help='Names of databases to restore from archive, defaults to all databases. Note that any databases to restore must be defined in borgmatic\'s configuration',
+        help="Names of databases to restore from archive, defaults to all databases. Note that any databases to restore must be defined in borgmatic's configuration",
     )
     restore_group.add_argument(
         '-h', '--help', action='help', help='Show this help message and exit'
@@ -805,7 +821,7 @@ def make_parsers():
         'borg',
         aliases=SUBPARSER_ALIASES['borg'],
         help='Run an arbitrary Borg command',
-        description='Run an arbitrary Borg command based on borgmatic\'s configuration',
+        description="Run an arbitrary Borg command based on borgmatic's configuration",
         add_help=False,
     )
     borg_group = borg_parser.add_argument_group('borg arguments')

+ 178 - 152
borgmatic/commands/borgmatic.py

@@ -44,8 +44,8 @@ LEGACY_CONFIG_PATH = '/etc/borgmatic/config'
 def run_configuration(config_filename, config, arguments):
     '''
     Given a config filename, the corresponding parsed config dict, and command-line arguments as a
-    dict from subparser name to a namespace of parsed arguments, execute the defined prune, compact,
-    create, check, and/or other actions.
+    dict from subparser name to a namespace of parsed arguments, execute the defined create, prune,
+    compact, check, and/or other actions.
 
     Yield a combination of:
 
@@ -64,7 +64,7 @@ def run_configuration(config_filename, config, arguments):
     retry_wait = storage.get('retry_wait', 0)
     encountered_error = None
     error_repository = ''
-    using_primary_action = {'prune', 'compact', 'create', 'check'}.intersection(arguments)
+    using_primary_action = {'create', 'prune', 'compact', 'check'}.intersection(arguments)
     monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
 
     try:
@@ -152,6 +152,25 @@ def run_configuration(config_filename, config, arguments):
                 encountered_error = error
                 error_repository = repository_path
 
+    try:
+        if using_primary_action:
+            # send logs irrespective of error
+            dispatch.call_hooks(
+                'ping_monitor',
+                hooks,
+                config_filename,
+                monitor.MONITOR_HOOK_NAMES,
+                monitor.State.LOG,
+                monitoring_log_level,
+                global_arguments.dry_run,
+            )
+    except (OSError, CalledProcessError) as error:
+        if command.considered_soft_failure(config_filename, error):
+            return
+
+        encountered_error = error
+        yield from log_error_records('{}: Error pinging monitor'.format(config_filename), error)
+
     if not encountered_error:
         try:
             if using_primary_action:
@@ -262,155 +281,162 @@ def run_actions(
         **hook_context,
     )
 
-    if 'rcreate' in arguments:
-        borgmatic.actions.rcreate.run_rcreate(
-            repository,
-            storage,
-            local_borg_version,
-            arguments['rcreate'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'transfer' in arguments:
-        borgmatic.actions.transfer.run_transfer(
-            repository,
-            storage,
-            local_borg_version,
-            arguments['transfer'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'prune' in arguments:
-        borgmatic.actions.prune.run_prune(
-            config_filename,
-            repository,
-            storage,
-            retention,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['prune'],
-            global_arguments,
-            dry_run_label,
-            local_path,
-            remote_path,
-        )
-    if 'compact' in arguments:
-        borgmatic.actions.compact.run_compact(
-            config_filename,
-            repository,
-            storage,
-            retention,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['compact'],
-            global_arguments,
-            dry_run_label,
-            local_path,
-            remote_path,
-        )
-    if 'create' in arguments:
-        yield from borgmatic.actions.create.run_create(
-            config_filename,
-            repository,
-            location,
-            storage,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['create'],
-            global_arguments,
-            dry_run_label,
-            local_path,
-            remote_path,
-        )
-    if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
-        borgmatic.actions.check.run_check(
-            config_filename,
-            repository,
-            location,
-            storage,
-            consistency,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['check'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'extract' in arguments:
-        borgmatic.actions.extract.run_extract(
-            config_filename,
-            repository,
-            location,
-            storage,
-            hooks,
-            hook_context,
-            local_borg_version,
-            arguments['extract'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'export-tar' in arguments:
-        borgmatic.actions.export_tar.run_export_tar(
-            repository,
-            storage,
-            local_borg_version,
-            arguments['export-tar'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'mount' in arguments:
-        borgmatic.actions.mount.run_mount(
-            repository, storage, local_borg_version, arguments['mount'], local_path, remote_path,
-        )
-    if 'restore' in arguments:
-        borgmatic.actions.restore.run_restore(
-            repository,
-            location,
-            storage,
-            hooks,
-            local_borg_version,
-            arguments['restore'],
-            global_arguments,
-            local_path,
-            remote_path,
-        )
-    if 'rlist' in arguments:
-        yield from borgmatic.actions.rlist.run_rlist(
-            repository, storage, local_borg_version, arguments['rlist'], local_path, remote_path,
-        )
-    if 'list' in arguments:
-        yield from borgmatic.actions.list.run_list(
-            repository, storage, local_borg_version, arguments['list'], local_path, remote_path,
-        )
-    if 'rinfo' in arguments:
-        yield from borgmatic.actions.rinfo.run_rinfo(
-            repository, storage, local_borg_version, arguments['rinfo'], local_path, remote_path,
-        )
-    if 'info' in arguments:
-        yield from borgmatic.actions.info.run_info(
-            repository, storage, local_borg_version, arguments['info'], local_path, remote_path,
-        )
-    if 'break-lock' in arguments:
-        borgmatic.actions.break_lock.run_break_lock(
-            repository,
-            storage,
-            local_borg_version,
-            arguments['break-lock'],
-            local_path,
-            remote_path,
-        )
-    if 'borg' in arguments:
-        borgmatic.actions.borg.run_borg(
-            repository, storage, local_borg_version, arguments['borg'], local_path, remote_path,
-        )
+    for (action_name, action_arguments) in arguments.items():
+        if action_name == 'rcreate':
+            borgmatic.actions.rcreate.run_rcreate(
+                repository,
+                storage,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'transfer':
+            borgmatic.actions.transfer.run_transfer(
+                repository,
+                storage,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'create':
+            yield from borgmatic.actions.create.run_create(
+                config_filename,
+                repository,
+                location,
+                storage,
+                hooks,
+                hook_context,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                dry_run_label,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'prune':
+            borgmatic.actions.prune.run_prune(
+                config_filename,
+                repository,
+                storage,
+                retention,
+                hooks,
+                hook_context,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                dry_run_label,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'compact':
+            borgmatic.actions.compact.run_compact(
+                config_filename,
+                repository,
+                storage,
+                retention,
+                hooks,
+                hook_context,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                dry_run_label,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'check':
+            if checks.repository_enabled_for_checks(repository, consistency):
+                borgmatic.actions.check.run_check(
+                    config_filename,
+                    repository,
+                    location,
+                    storage,
+                    consistency,
+                    hooks,
+                    hook_context,
+                    local_borg_version,
+                    action_arguments,
+                    global_arguments,
+                    local_path,
+                    remote_path,
+                )
+        elif action_name == 'extract':
+            borgmatic.actions.extract.run_extract(
+                config_filename,
+                repository,
+                location,
+                storage,
+                hooks,
+                hook_context,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'export-tar':
+            borgmatic.actions.export_tar.run_export_tar(
+                repository,
+                storage,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'mount':
+            borgmatic.actions.mount.run_mount(
+                repository,
+                storage,
+                local_borg_version,
+                arguments['mount'],
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'restore':
+            borgmatic.actions.restore.run_restore(
+                repository,
+                location,
+                storage,
+                hooks,
+                local_borg_version,
+                action_arguments,
+                global_arguments,
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'rlist':
+            yield from borgmatic.actions.rlist.run_rlist(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
+        elif action_name == 'list':
+            yield from borgmatic.actions.list.run_list(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
+        elif action_name == 'rinfo':
+            yield from borgmatic.actions.rinfo.run_rinfo(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
+        elif action_name == 'info':
+            yield from borgmatic.actions.info.run_info(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
+        elif action_name == 'break-lock':
+            borgmatic.actions.break_lock.run_break_lock(
+                repository,
+                storage,
+                local_borg_version,
+                arguments['break-lock'],
+                local_path,
+                remote_path,
+            )
+        elif action_name == 'borg':
+            borgmatic.actions.borg.run_borg(
+                repository, storage, local_borg_version, action_arguments, local_path, remote_path,
+            )
 
     command.execute_hook(
         hooks.get('after_actions'),

+ 28 - 14
borgmatic/config/schema.yaml

@@ -202,6 +202,12 @@ properties:
                     path prevents "borgmatic restore" from finding any database
                     dumps created before the change. Defaults to ~/.borgmatic
                 example: /tmp/borgmatic
+            source_directories_must_exist:
+                type: boolean
+                description: |
+                    If true, then source directories must exist, otherwise an
+                    error is raised. Defaults to false.
+                example: true
     storage:
         type: object
         description: |
@@ -315,6 +321,12 @@ properties:
                     Path for Borg cache files. Defaults to
                     $borg_base_directory/.cache/borg
                 example: /path/to/base/cache
+            borg_files_cache_ttl:
+                type: integer
+                description: |
+                    Maximum time to live (ttl) for entries in the Borg files
+                    cache.
+                example: 20
             borg_security_directory:
                 type: string
                 description: |
@@ -369,6 +381,11 @@ properties:
                         description: |
                           Extra command-line options to pass to "borg init".
                         example: "--extra-option"
+                    create:
+                        type: string
+                        description: |
+                          Extra command-line options to pass to "borg create".
+                        example: "--extra-option"
                     prune:
                         type: string
                         description: |
@@ -379,11 +396,6 @@ properties:
                         description: |
                           Extra command-line options to pass to "borg compact".
                         example: "--extra-option"
-                    create:
-                        type: string
-                        description: |
-                          Extra command-line options to pass to "borg create".
-                        example: "--extra-option"
                     check:
                         type: string
                         description: |
@@ -663,11 +675,11 @@ properties:
                     type: string
                 description: |
                     List of one or more shell commands or scripts to execute
-                    when an exception occurs during a "prune", "compact",
-                    "create", or "check" action or an associated before/after
+                    when an exception occurs during a "create", "prune",
+                    "compact", or "check" action or an associated before/after
                     hook.
                 example:
-                    - echo "Error during prune/compact/create/check."
+                    - echo "Error during create/prune/compact/check."
             before_everything:
                 type: array
                 items:
@@ -951,9 +963,9 @@ properties:
                         name:
                             type: string
                             description: |
-                                This is used to tag the database dump file 
-                                with a name. It is not the path to the database 
-                                file itself. The name "all" has no special 
+                                This is used to tag the database dump file
+                                with a name. It is not the path to the database
+                                file itself. The name "all" has no special
                                 meaning for SQLite databases.
                             example: users
                         path:
@@ -1168,7 +1180,7 @@ properties:
                         type: string
                         description: |
                             Healthchecks ping URL or UUID to notify when a
-                            backup begins, ends, or errors.
+                            backup begins, ends, errors or just to send logs.
                         example: https://hc-ping.com/your-uuid-here
                     verify_tls:
                         type: boolean
@@ -1180,7 +1192,8 @@ properties:
                         type: boolean
                         description: |
                             Send borgmatic logs to Healthchecks as part the
-                            "finish" state. Defaults to true.
+                            "finish", "fail", and "log" states. Defaults to
+                            true.
                         example: false
                     ping_body_limit:
                         type: integer
@@ -1199,10 +1212,11 @@ properties:
                                 - start
                                 - finish
                                 - fail
+                                - log
                             uniqueItems: true
                         description: |
                             List of one or more monitoring states to ping for:
-                            "start", "finish", and/or "fail". Defaults to
+                            "start", "finish", "fail", and/or "log". Defaults to
                             pinging for all states.
                         example:
                             - finish

+ 1 - 1
borgmatic/config/validate.py

@@ -189,5 +189,5 @@ def guard_single_repository_selected(repository, configurations):
 
     if count != 1:
         raise ValueError(
-            'Can\'t determine which repository to use. Use --repository to disambiguate'
+            "Can't determine which repository to use. Use --repository to disambiguate"
         )

+ 6 - 0
borgmatic/hooks/cronhub.py

@@ -27,6 +27,12 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
     Ping the configured Cronhub URL, modified with the monitor.State. Use the given configuration
     filename in any log entries. If this is a dry run, then don't actually ping anything.
     '''
+    if state not in MONITOR_STATE_TO_CRONHUB:
+        logger.debug(
+            f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook'
+        )
+        return
+
     dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
     formatted_state = '/{}/'.format(MONITOR_STATE_TO_CRONHUB[state])
     ping_url = (

+ 6 - 0
borgmatic/hooks/cronitor.py

@@ -27,6 +27,12 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
     Ping the configured Cronitor URL, modified with the monitor.State. Use the given configuration
     filename in any log entries. If this is a dry run, then don't actually ping anything.
     '''
+    if state not in MONITOR_STATE_TO_CRONITOR:
+        logger.debug(
+            f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronitor hook'
+        )
+        return
+
     dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
     ping_url = '{}/{}'.format(hook_config['ping_url'], MONITOR_STATE_TO_CRONITOR[state])
 

+ 2 - 1
borgmatic/hooks/healthchecks.py

@@ -10,6 +10,7 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
     monitor.State.START: 'start',
     monitor.State.FINISH: None,  # Healthchecks doesn't append to the URL for the finished state.
     monitor.State.FAIL: 'fail',
+    monitor.State.LOG: 'log',
 }
 
 PAYLOAD_TRUNCATION_INDICATOR = '...\n'
@@ -117,7 +118,7 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
     )
     logger.debug('{}: Using Healthchecks ping URL {}'.format(config_filename, ping_url))
 
-    if state in (monitor.State.FINISH, monitor.State.FAIL):
+    if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
         payload = format_buffered_logs_for_payload()
     else:
         payload = ''

+ 1 - 0
borgmatic/hooks/monitor.py

@@ -7,3 +7,4 @@ class State(Enum):
     START = 1
     FINISH = 2
     FAIL = 3
+    LOG = 4

+ 0 - 8
borgmatic/hooks/ntfy.py

@@ -2,16 +2,8 @@ import logging
 
 import requests
 
-from borgmatic.hooks import monitor
-
 logger = logging.getLogger(__name__)
 
-MONITOR_STATE_TO_NTFY = {
-    monitor.State.START: None,
-    monitor.State.FINISH: None,
-    monitor.State.FAIL: None,
-}
-
 
 def initialize_monitor(
     ping_url, config_filename, monitoring_log_level, dry_run

+ 1 - 1
docs/Dockerfile

@@ -4,7 +4,7 @@ COPY . /app
 RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib
 RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
 RUN borgmatic --help > /command-line.txt \
-    && for action in rcreate transfer prune compact create check extract export-tar mount umount restore rlist list rinfo info break-lock borg; do \
+    && for action in rcreate transfer create prune compact check extract export-tar mount umount restore rlist list rinfo info break-lock borg; do \
            echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
            && borgmatic "$action" --help >> /command-line.txt; done
 

+ 4 - 1
docs/how-to/backup-your-databases.md

@@ -316,7 +316,10 @@ user and you're extracting to `/tmp`, then the dump will be in
 `/tmp/root/.borgmatic`.
 
 After extraction, you can manually restore the dump file using native database
-commands like `pg_restore`, `mysql`, `mongorestore` or similar.
+commands like `pg_restore`, `mysql`, `mongorestore`, `sqlite`, or similar.
+
+Also see the documentation on [listing database
+dumps](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#listing-database-dumps).
 
 
 ## Preparation and cleanup hooks

+ 38 - 22
docs/how-to/deal-with-very-large-backups.md

@@ -9,37 +9,47 @@ eleventyNavigation:
 
 Borg itself is great for efficiently de-duplicating data across successive
 backup archives, even when dealing with very large repositories. But you may
-find that while borgmatic's default mode of `prune`, `compact`, `create`, and
-`check` works well on small repositories, it's not so great on larger ones.
-That's because running the default pruning, compact, and consistency checks
-take a long time on large repositories.
+find that while borgmatic's default actions of `create`, `prune`, `compact`,
+and `check` works well on small repositories, it's not so great on larger
+ones. That's because running the default pruning, compact, and consistency
+checks take a long time on large repositories.
+
+<span class="minilink minilink-addedin">Prior to version 1.7.9</span> The
+default action ordering was `prune`, `compact`, `create`, and `check`.
 
 ### A la carte actions
 
-If you find yourself in this situation, you have some options. First, you can
-run borgmatic's `prune`, `compact`, `create`, or `check` actions separately.
-For instance, the following optional actions are available:
+If you find yourself wanting to customize the actions, you have some options.
+First, you can run borgmatic's `prune`, `compact`, `create`, or `check`
+actions separately. For instance, the following optional actions are
+available (among others):
 
 ```bash
+borgmatic create
 borgmatic prune
 borgmatic compact
-borgmatic create
 borgmatic check
 ```
 
-You can run with only one of these actions provided, or you can mix and match
-any number of them in a single borgmatic run. This supports approaches like
-skipping certain actions while running others. For instance, this skips
-`prune` and `compact` and only runs `create` and `check`:
+You can run borgmatic with only one of these actions provided, or you can mix
+and match any number of them in a single borgmatic run. This supports
+approaches like skipping certain actions while running others. For instance,
+this skips `prune` and `compact` and only runs `create` and `check`:
 
 ```bash
 borgmatic create check
 ```
 
-Or, you can make backups with `create` on a frequent schedule (e.g. with
-`borgmatic create` called from one cron job), while only running expensive
-consistency checks with `check` on a much less frequent basis (e.g. with
-`borgmatic check` called from a separate cron job).
+<span class="minilink minilink-addedin">New in version 1.7.9</span> borgmatic
+now respects your specified command-line action order, running actions in the
+order you specify. In previous versions, borgmatic ran your specified actions
+in a fixed ordering regardless of the order they appeared on the command-line.
+
+But instead of running actions together, another option is to run backups with
+`create` on a frequent schedule (e.g. with `borgmatic create` called from one
+cron job), while only running expensive consistency checks with `check` on a
+much less frequent basis (e.g. with `borgmatic check` called from a separate
+cron job).
 
 
 ### Consistency check configuration
@@ -47,8 +57,8 @@ consistency checks with `check` on a much less frequent basis (e.g. with
 Another option is to customize your consistency checks. By default, if you
 omit consistency checks from configuration, borgmatic runs full-repository
 checks (`repository`) and per-archive checks (`archives`) within each
-repository, no more than once a month. This is equivalent to what `borg check`
-does if run without options.
+repository. (Although see below about check frequency.) This is equivalent to
+what `borg check` does if run without options.
 
 But if you find that archive checks are too slow, for example, you can
 configure borgmatic to run repository checks only. Configure this in the
@@ -60,8 +70,9 @@ consistency:
         - name: repository
 ```
 
-<span class="minilink minilink-addedin">Prior to version 1.6.2</span> `checks`
-was a plain list of strings without the `name:` part. For example:
+<span class="minilink minilink-addedin">Prior to version 1.6.2</span> The
+`checks` option was a plain list of strings without the `name:` part, and
+borgmatic ran each configured check every time checks were run. For example:
 
 ```yaml
 consistency:
@@ -102,8 +113,13 @@ consistency:
 This tells borgmatic to run the `repository` consistency check at most once
 every two weeks for a given repository and the `archives` check at most once a
 month. The `frequency` value is a number followed by a unit of time, e.g. "3
-days", "1 week", "2 months", etc. The `frequency` defaults to `always`, which
-means run this check every time checks run.
+days", "1 week", "2 months", etc.
+
+The `frequency` defaults to `always` for a check configured without a
+`frequency`, which means run this check every time checks run. But if you omit
+consistency checks from configuration entirely, borgmatic runs full-repository
+checks (`repository`) and per-archive checks (`archives`) within each
+repository, at most once a month.
 
 Unlike a real scheduler like cron, borgmatic only makes a best effort to run
 checks on the configured frequency. It compares that frequency with how long

+ 1 - 1
docs/how-to/develop-on-borgmatic.md

@@ -26,7 +26,7 @@ make sure your changes work.
 
 ```bash
 cd borgmatic/
-pip3 install --editable --user .
+pip3 install --user --editable .
 ```
 
 Note that this will typically install the borgmatic commands into

+ 11 - 4
docs/how-to/extract-a-backup.md

@@ -20,15 +20,15 @@ borgmatic rlist
 That should yield output looking something like:
 
 ```text
-host-2019-01-01T04:05:06.070809      Tue, 2019-01-01 04:05:06 [...]
-host-2019-01-02T04:06:07.080910      Wed, 2019-01-02 04:06:07 [...]
+host-2023-01-01T04:05:06.070809      Tue, 2023-01-01 04:05:06 [...]
+host-2023-01-02T04:06:07.080910      Wed, 2023-01-02 04:06:07 [...]
 ```
 
 Assuming that you want to extract the archive with the most up-to-date files
 and therefore the latest timestamp, run a command like:
 
 ```bash
-borgmatic extract --archive host-2019-01-02T04:06:07.080910
+borgmatic extract --archive host-2023-01-02T04:06:07.080910
 ```
 
 (No borgmatic `extract` action? Upgrade borgmatic!)
@@ -54,7 +54,7 @@ But if you have multiple repositories configured, then you'll need to specify
 the repository path containing the archive to extract. Here's an example:
 
 ```bash
-borgmatic extract --repository repo.borg --archive host-2019-...
+borgmatic extract --repository repo.borg --archive host-2023-...
 ```
 
 ## Extract particular files
@@ -74,6 +74,13 @@ run the `extract` command above, borgmatic will extract `/var/path/1` and
 `/var/path/2`.
 
 
+### Searching for files
+
+If you're not sure which archive contains the files you're looking for, you
+can [search across
+archives](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#searching-for-a-file).
+
+
 ## Extract to a particular destination
 
 By default, borgmatic extracts files into the current directory. To instead

+ 13 - 0
docs/how-to/inspect-your-backups.md

@@ -91,6 +91,19 @@ example, to search only the last five archives:
 borgmatic list --find foo.txt --last 5
 ```
 
+## Listing database dumps
+
+If you have enabled borgmatic's [database
+hooks](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/), you
+can list backed up database dumps via borgmatic. For example:
+
+```bash 
+borgmatic list --archive latest --find .borgmatic/*_databases
+```
+
+This gives you a listing of all database dump files contained in the latest
+archive, complete with file sizes.
+
 
 ## Logging
 

+ 6 - 6
docs/how-to/monitor-your-backups.md

@@ -83,7 +83,7 @@ tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/).
 
 ## Error hooks
 
-When an error occurs during a `prune`, `compact`, `create`, or `check` action,
+When an error occurs during a `create`, `prune`, `compact`, or `check` action,
 borgmatic can run configurable shell commands to fire off custom error
 notifications or take other actions, so you can get alerted as soon as
 something goes wrong. Here's a not-so-useful example:
@@ -116,8 +116,8 @@ the repository. Here's the full set of supported variables you can use here:
  * `output`: output of the command that failed (may be blank if an error
    occurred without running a command)
 
-Note that borgmatic runs the `on_error` hooks only for `prune`, `compact`,
-`create`, or `check` actions or hooks in which an error occurs, and not other
+Note that borgmatic runs the `on_error` hooks only for `create`, `prune`,
+`compact`, or `check` actions or hooks in which an error occurs, and not other
 actions. borgmatic does not run `on_error` hooks if an error occurs within a
 `before_everything` or `after_everything` hook. For more about hooks, see the
 [borgmatic hooks
@@ -144,7 +144,7 @@ With this hook in place, borgmatic pings your Healthchecks project when a
 backup begins, ends, or errors. Specifically, after the <a
 href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
 hooks</a> run, borgmatic lets Healthchecks know that it has started if any of
-the `prune`, `compact`, `create`, or `check` actions are run.
+the `create`, `prune`, `compact`, or `check` actions are run.
 
 Then, if the actions complete successfully, borgmatic notifies Healthchecks of
 the success after the `after_backup` hooks run, and includes borgmatic logs in
@@ -154,8 +154,8 @@ in the Healthchecks UI, although be aware that Healthchecks currently has a
 
 If an error occurs during any action or hook, borgmatic notifies Healthchecks
 after the `on_error` hooks run, also tacking on logs including the error
-itself. But the logs are only included for errors that occur when a `prune`,
-`compact`, `create`, or `check` action is run.
+itself. But the logs are only included for errors that occur when a `create`,
+`prune`, `compact`, or `check` action is run.
 
 You can customize the verbosity of the logs that are sent to Healthchecks with
 borgmatic's `--monitoring-verbosity` flag. The `--list` and `--stats` flags

+ 1 - 0
docs/how-to/set-up-backups.md

@@ -94,6 +94,7 @@ installing borgmatic:
  * [openSUSE](https://software.opensuse.org/package/borgmatic)
  * [macOS (via Homebrew)](https://formulae.brew.sh/formula/borgmatic)
  * [macOS (via MacPorts)](https://ports.macports.org/port/borgmatic/)
+ * [NixOS](https://search.nixos.org/packages?show=borgmatic&sort=relevance&type=packages&query=borgmatic)
  * [Ansible role](https://github.com/borgbase/ansible-role-borgbackup)
  * [virtualenv](https://virtualenv.pypa.io/en/stable/)
 

+ 2 - 0
setup.cfg

@@ -10,6 +10,8 @@ filterwarnings =
 [flake8]
 ignore = E501,W503
 exclude = *.*/*
+multiline-quotes = '''
+docstring-quotes = '''
 
 [tool:isort]
 force_single_line = False

+ 1 - 1
setup.py

@@ -1,6 +1,6 @@
 from setuptools import find_packages, setup
 
-VERSION = '1.7.9.dev0'
+VERSION = '1.7.10.dev0'
 
 
 setup(

+ 1 - 0
test_requirements.txt

@@ -5,6 +5,7 @@ click==7.1.2; python_version >= '3.8'
 colorama==0.4.4
 coverage==5.3
 flake8==4.0.1
+flake8-quotes==3.3.2
 flexmock==0.10.4
 isort==5.9.1
 mccabe==0.6.1

+ 0 - 7
tests/integration/commands/test_arguments.py

@@ -254,13 +254,6 @@ def test_parse_arguments_allows_init_and_create():
     module.parse_arguments('--config', 'myconfig', 'init', '--encryption', 'repokey', 'create')
 
 
-def test_parse_arguments_disallows_repository_unless_action_consumes_it():
-    flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
-
-    with pytest.raises(SystemExit):
-        module.parse_arguments('--config', 'myconfig', '--repository', 'test.borg')
-
-
 def test_parse_arguments_allows_repository_with_extract():
     flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
 

+ 66 - 3
tests/unit/actions/test_check.py

@@ -3,15 +3,78 @@ from flexmock import flexmock
 from borgmatic.actions import check as module
 
 
-def test_run_check_calls_hooks():
+def test_run_check_calls_hooks_for_configured_repository():
     flexmock(module.logger).answer = lambda message: None
     flexmock(module.borgmatic.config.checks).should_receive(
         'repository_enabled_for_checks'
     ).and_return(True)
-    flexmock(module.borgmatic.borg.check).should_receive('check_archives')
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
+    flexmock(module.borgmatic.borg.check).should_receive('check_archives').once()
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     check_arguments = flexmock(
-        progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock()
+        repository=None, progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock(),
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_check(
+        config_filename='test.yaml',
+        repository='repo',
+        location={'repositories': ['repo']},
+        storage={},
+        consistency={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        check_arguments=check_arguments,
+        global_arguments=global_arguments,
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_run_check_runs_with_selected_repository():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(True)
+    flexmock(module.borgmatic.borg.check).should_receive('check_archives').once()
+    check_arguments = flexmock(
+        repository=flexmock(),
+        progress=flexmock(),
+        repair=flexmock(),
+        only=flexmock(),
+        force=flexmock(),
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_check(
+        config_filename='test.yaml',
+        repository=flexmock(),
+        location={'repositories': ['repo']},
+        storage={},
+        consistency={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        check_arguments=check_arguments,
+        global_arguments=global_arguments,
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_run_check_bails_if_repository_does_not_match():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(False)
+    flexmock(module.borgmatic.borg.check).should_receive('check_archives').never()
+    check_arguments = flexmock(
+        repository=flexmock(),
+        progress=flexmock(),
+        repair=flexmock(),
+        only=flexmock(),
+        force=flexmock(),
     )
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
 

+ 60 - 3
tests/unit/actions/test_compact.py

@@ -3,13 +3,70 @@ from flexmock import flexmock
 from borgmatic.actions import compact as module
 
 
-def test_compact_actions_calls_hooks():
+def test_compact_actions_calls_hooks_for_configured_repository():
     flexmock(module.logger).answer = lambda message: None
     flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
-    flexmock(module.borgmatic.borg.compact).should_receive('compact_segments')
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
+    flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').once()
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     compact_arguments = flexmock(
-        progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
+        repository=None, progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_compact(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        compact_arguments=compact_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_compact_runs_with_selected_repository():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(True)
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
+    flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').once()
+    compact_arguments = flexmock(
+        repository=flexmock(), progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_compact(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        compact_arguments=compact_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_compact_bails_if_repository_does_not_match():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(False)
+    flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').never()
+    compact_arguments = flexmock(
+        repository=flexmock(), progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
     )
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
 

+ 74 - 3
tests/unit/actions/test_create.py

@@ -3,16 +3,87 @@ from flexmock import flexmock
 from borgmatic.actions import create as module
 
 
-def test_run_create_executes_and_calls_hooks():
+def test_run_create_executes_and_calls_hooks_for_configured_repository():
     flexmock(module.logger).answer = lambda message: None
-    flexmock(module.borgmatic.borg.create).should_receive('create_archive')
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
+    flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
     flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return({})
     flexmock(module.borgmatic.hooks.dispatch).should_receive(
         'call_hooks_even_if_unconfigured'
     ).and_return({})
     create_arguments = flexmock(
-        progress=flexmock(), stats=flexmock(), json=flexmock(), list_files=flexmock()
+        repository=None,
+        progress=flexmock(),
+        stats=flexmock(),
+        json=flexmock(),
+        list_files=flexmock(),
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    list(
+        module.run_create(
+            config_filename='test.yaml',
+            repository='repo',
+            location={},
+            storage={},
+            hooks={},
+            hook_context={},
+            local_borg_version=None,
+            create_arguments=create_arguments,
+            global_arguments=global_arguments,
+            dry_run_label='',
+            local_path=None,
+            remote_path=None,
+        )
+    )
+
+
+def test_run_create_runs_with_selected_repository():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(True)
+    flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
+    create_arguments = flexmock(
+        repository=flexmock(),
+        progress=flexmock(),
+        stats=flexmock(),
+        json=flexmock(),
+        list_files=flexmock(),
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    list(
+        module.run_create(
+            config_filename='test.yaml',
+            repository='repo',
+            location={},
+            storage={},
+            hooks={},
+            hook_context={},
+            local_borg_version=None,
+            create_arguments=create_arguments,
+            global_arguments=global_arguments,
+            dry_run_label='',
+            local_path=None,
+            remote_path=None,
+        )
+    )
+
+
+def test_run_create_bails_if_repository_does_not_match():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(False)
+    flexmock(module.borgmatic.borg.create).should_receive('create_archive').never()
+    create_arguments = flexmock(
+        repository=flexmock(),
+        progress=flexmock(),
+        stats=flexmock(),
+        json=flexmock(),
+        list_files=flexmock(),
     )
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
 

+ 54 - 3
tests/unit/actions/test_prune.py

@@ -3,11 +3,62 @@ from flexmock import flexmock
 from borgmatic.actions import prune as module
 
 
-def test_run_prune_calls_hooks():
+def test_run_prune_calls_hooks_for_configured_repository():
     flexmock(module.logger).answer = lambda message: None
-    flexmock(module.borgmatic.borg.prune).should_receive('prune_archives')
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
+    flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').once()
     flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
-    prune_arguments = flexmock(stats=flexmock(), list_archives=flexmock())
+    prune_arguments = flexmock(repository=None, stats=flexmock(), list_archives=flexmock())
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_prune(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        prune_arguments=prune_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_run_prune_runs_with_selected_repository():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(True)
+    flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').once()
+    prune_arguments = flexmock(repository=flexmock(), stats=flexmock(), list_archives=flexmock())
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_prune(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        prune_arguments=prune_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_run_prune_bails_if_repository_does_not_match():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive(
+        'repositories_match'
+    ).once().and_return(False)
+    flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').never()
+    prune_arguments = flexmock(repository=flexmock(), stats=flexmock(), list_archives=flexmock())
     global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
 
     module.run_prune(

+ 25 - 2
tests/unit/borg/test_create.py

@@ -207,7 +207,6 @@ def test_make_exclude_flags_includes_exclude_patterns_filename_when_given():
 
 
 def test_make_exclude_flags_includes_exclude_from_filenames_when_in_config():
-
     exclude_flags = module.make_exclude_flags(
         location_config={'exclude_from': ['excludes', 'other']}
     )
@@ -1916,7 +1915,7 @@ def test_create_archive_with_stream_processes_ignores_read_special_false_and_log
         (f'repo::{DEFAULT_ARCHIVE_NAME}',)
     )
     flexmock(module.environment).should_receive('make_environment')
-    flexmock(module).should_receive('collect_special_file_paths').and_return(("/dev/null",))
+    flexmock(module).should_receive('collect_special_file_paths').and_return(('/dev/null',))
     create_command = (
         'borg',
         'create',
@@ -2530,3 +2529,27 @@ def test_create_archive_with_stream_processes_calls_borg_with_processes_and_read
         local_borg_version='1.2.3',
         stream_processes=processes,
     )
+
+
+def test_create_archive_with_non_existent_directory_and_source_directories_must_exist_raises_error():
+    '''
+    If a source directory doesn't exist and source_directories_must_exist is True, raise an error.
+    '''
+    flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
+    flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
+    flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([])
+    flexmock(module.os.path).should_receive('exists').and_return(False)
+
+    with pytest.raises(ValueError):
+        module.create_archive(
+            dry_run=False,
+            repository='repo',
+            location_config={
+                'source_directories': ['foo', 'bar'],
+                'repositories': ['repo'],
+                'exclude_patterns': None,
+                'source_directories_must_exist': True,
+            },
+            storage_config={},
+            local_borg_version='1.2.3',
+        )

+ 5 - 0
tests/unit/borg/test_environment.py

@@ -32,3 +32,8 @@ def test_make_environment_with_relocated_repo_access_should_override_default():
     environment = module.make_environment({'relocated_repo_access_is_ok': True})
 
     assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'yes'
+
+
+def test_make_environment_with_integer_variable_value():
+    environment = module.make_environment({'borg_files_cache_ttl': 40})
+    assert environment.get('BORG_FILES_CACHE_TTL') == '40'

+ 51 - 0
tests/unit/borg/test_extract.py

@@ -312,6 +312,57 @@ def test_extract_archive_calls_borg_with_strip_components():
     )
 
 
+def test_extract_archive_calls_borg_with_strip_components_calculated_from_all():
+    flexmock(module.os.path).should_receive('abspath').and_return('repo')
+    insert_execute_command_mock(
+        (
+            'borg',
+            'extract',
+            '--strip-components',
+            '2',
+            'repo::archive',
+            'foo/bar/baz.txt',
+            'foo/bar.txt',
+        )
+    )
+    flexmock(module.feature).should_receive('available').and_return(True)
+    flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
+        ('repo::archive',)
+    )
+
+    module.extract_archive(
+        dry_run=False,
+        repository='repo',
+        archive='archive',
+        paths=['foo/bar/baz.txt', 'foo/bar.txt'],
+        location_config={},
+        storage_config={},
+        local_borg_version='1.2.3',
+        strip_components='all',
+    )
+
+
+def test_extract_archive_with_strip_components_all_and_no_paths_raises():
+    flexmock(module.os.path).should_receive('abspath').and_return('repo')
+    flexmock(module.feature).should_receive('available').and_return(True)
+    flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
+        ('repo::archive',)
+    )
+    flexmock(module).should_receive('execute_command').never()
+
+    with pytest.raises(ValueError):
+        module.extract_archive(
+            dry_run=False,
+            repository='repo',
+            archive='archive',
+            paths=None,
+            location_config={},
+            storage_config={},
+            local_borg_version='1.2.3',
+            strip_components='all',
+        )
+
+
 def test_extract_archive_calls_borg_with_progress_parameter():
     flexmock(module.os.path).should_receive('abspath').and_return('repo')
     flexmock(module.environment).should_receive('make_environment')

+ 22 - 0
tests/unit/commands/test_arguments.py

@@ -1,3 +1,5 @@
+import collections
+
 from flexmock import flexmock
 
 from borgmatic.commands import arguments as module
@@ -70,6 +72,26 @@ def test_parse_subparser_arguments_consumes_multiple_subparser_arguments():
     assert remaining_arguments == []
 
 
+def test_parse_subparser_arguments_respects_command_line_action_ordering():
+    other_namespace = flexmock()
+    action_namespace = flexmock(foo=True)
+    subparsers = {
+        'action': flexmock(
+            parse_known_args=lambda arguments: (action_namespace, ['action', '--foo', 'true'])
+        ),
+        'other': flexmock(parse_known_args=lambda arguments: (other_namespace, ['other'])),
+    }
+
+    arguments, remaining_arguments = module.parse_subparser_arguments(
+        ('other', '--foo', 'true', 'action'), subparsers
+    )
+
+    assert arguments == collections.OrderedDict(
+        [('other', other_namespace), ('action', action_namespace)]
+    )
+    assert remaining_arguments == []
+
+
 def test_parse_subparser_arguments_applies_default_subparsers():
     prune_namespace = flexmock()
     compact_namespace = flexmock()

+ 78 - 16
tests/unit/commands/test_borgmatic.py

@@ -40,7 +40,7 @@ def test_run_configuration_logs_monitor_start_error():
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     flexmock(module.dispatch).should_receive('call_hooks').and_raise(OSError).and_return(
         None
-    ).and_return(None)
+    ).and_return(None).and_return(None)
     expected_results = [flexmock()]
     flexmock(module).should_receive('log_error_records').and_return(expected_results)
     flexmock(module).should_receive('run_actions').never()
@@ -99,7 +99,7 @@ def test_run_configuration_bails_for_actions_soft_failure():
     assert results == []
 
 
-def test_run_configuration_logs_monitor_finish_error():
+def test_run_configuration_logs_monitor_log_error():
     flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
@@ -116,7 +116,7 @@ def test_run_configuration_logs_monitor_finish_error():
     assert results == expected_results
 
 
-def test_run_configuration_bails_for_monitor_finish_soft_failure():
+def test_run_configuration_bails_for_monitor_log_soft_failure():
     flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
     error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
@@ -134,6 +134,41 @@ def test_run_configuration_bails_for_monitor_finish_soft_failure():
     assert results == []
 
 
+def test_run_configuration_logs_monitor_finish_error():
+    flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
+    flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
+    flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
+        None
+    ).and_return(None).and_raise(OSError)
+    expected_results = [flexmock()]
+    flexmock(module).should_receive('log_error_records').and_return(expected_results)
+    flexmock(module).should_receive('run_actions').and_return([])
+    config = {'location': {'repositories': ['foo']}}
+    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
+
+    results = list(module.run_configuration('test.yaml', config, arguments))
+
+    assert results == expected_results
+
+
+def test_run_configuration_bails_for_monitor_finish_soft_failure():
+    flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
+    flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
+    error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
+    flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
+        None
+    ).and_raise(None).and_raise(error)
+    flexmock(module).should_receive('log_error_records').never()
+    flexmock(module).should_receive('run_actions').and_return([])
+    flexmock(module.command).should_receive('considered_soft_failure').and_return(True)
+    config = {'location': {'repositories': ['foo']}}
+    arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
+
+    results = list(module.run_configuration('test.yaml', config, arguments))
+
+    assert results == []
+
+
 def test_run_configuration_logs_on_error_hook_error():
     flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
     flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
@@ -401,14 +436,15 @@ def test_run_actions_runs_transfer():
     )
 
 
-def test_run_actions_runs_prune():
+def test_run_actions_runs_create():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module.command).should_receive('execute_hook')
-    flexmock(borgmatic.actions.prune).should_receive('run_prune').once()
+    expected = flexmock()
+    flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once()
 
-    tuple(
+    result = tuple(
         module.run_actions(
-            arguments={'global': flexmock(dry_run=False), 'prune': flexmock()},
+            arguments={'global': flexmock(dry_run=False), 'create': flexmock()},
             config_filename=flexmock(),
             location={'repositories': []},
             storage=flexmock(),
@@ -421,16 +457,17 @@ def test_run_actions_runs_prune():
             repository_path='repo',
         )
     )
+    assert result == (expected,)
 
 
-def test_run_actions_runs_compact():
+def test_run_actions_runs_prune():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module.command).should_receive('execute_hook')
-    flexmock(borgmatic.actions.compact).should_receive('run_compact').once()
+    flexmock(borgmatic.actions.prune).should_receive('run_prune').once()
 
     tuple(
         module.run_actions(
-            arguments={'global': flexmock(dry_run=False), 'compact': flexmock()},
+            arguments={'global': flexmock(dry_run=False), 'prune': flexmock()},
             config_filename=flexmock(),
             location={'repositories': []},
             storage=flexmock(),
@@ -445,15 +482,14 @@ def test_run_actions_runs_compact():
     )
 
 
-def test_run_actions_runs_create():
+def test_run_actions_runs_compact():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module.command).should_receive('execute_hook')
-    expected = flexmock()
-    flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once()
+    flexmock(borgmatic.actions.compact).should_receive('run_compact').once()
 
-    result = tuple(
+    tuple(
         module.run_actions(
-            arguments={'global': flexmock(dry_run=False), 'create': flexmock()},
+            arguments={'global': flexmock(dry_run=False), 'compact': flexmock()},
             config_filename=flexmock(),
             location={'repositories': []},
             storage=flexmock(),
@@ -466,7 +502,6 @@ def test_run_actions_runs_create():
             repository_path='repo',
         )
     )
-    assert result == (expected,)
 
 
 def test_run_actions_runs_check_when_repository_enabled_for_checks():
@@ -743,6 +778,33 @@ def test_run_actions_runs_borg():
     )
 
 
+def test_run_actions_runs_multiple_actions_in_argument_order():
+    flexmock(module).should_receive('add_custom_log_levels')
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.borg).should_receive('run_borg').once().ordered()
+    flexmock(borgmatic.actions.restore).should_receive('run_restore').once().ordered()
+
+    tuple(
+        module.run_actions(
+            arguments={
+                'global': flexmock(dry_run=False),
+                'borg': flexmock(),
+                'restore': flexmock(),
+            },
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
+            hooks={},
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
+            repository_path='repo',
+        )
+    )
+
+
 def test_load_configurations_collects_parsed_configurations_and_logs():
     configuration = flexmock()
     other_configuration = flexmock()

+ 8 - 0
tests/unit/hooks/test_cronhub.py

@@ -102,3 +102,11 @@ def test_ping_monitor_with_other_error_logs_warning():
         monitoring_log_level=1,
         dry_run=False,
     )
+
+
+def test_ping_monitor_with_unsupported_monitoring_state():
+    hook_config = {'ping_url': 'https://example.com'}
+    flexmock(module.requests).should_receive('get').never()
+    module.ping_monitor(
+        hook_config, 'config.yaml', module.monitor.State.LOG, monitoring_log_level=1, dry_run=False,
+    )

+ 8 - 0
tests/unit/hooks/test_cronitor.py

@@ -87,3 +87,11 @@ def test_ping_monitor_with_other_error_logs_warning():
         monitoring_log_level=1,
         dry_run=False,
     )
+
+
+def test_ping_monitor_with_unsupported_monitoring_state():
+    hook_config = {'ping_url': 'https://example.com'}
+    flexmock(module.requests).should_receive('get').never()
+    module.ping_monitor(
+        hook_config, 'config.yaml', module.monitor.State.LOG, monitoring_log_level=1, dry_run=False,
+    )

+ 17 - 0
tests/unit/hooks/test_healthchecks.py

@@ -184,6 +184,23 @@ def test_ping_monitor_hits_ping_url_for_fail_state():
     )
 
 
+def test_ping_monitor_hits_ping_url_for_log_state():
+    hook_config = {'ping_url': 'https://example.com'}
+    payload = 'data'
+    flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
+    flexmock(module.requests).should_receive('post').with_args(
+        'https://example.com/log', data=payload.encode('utf'), verify=True
+    ).and_return(flexmock(ok=True))
+
+    module.ping_monitor(
+        hook_config,
+        'config.yaml',
+        state=module.monitor.State.LOG,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
 def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
     hook_config = {'ping_url': 'abcd-efgh-ijkl-mnop'}
     payload = 'data'

+ 1 - 1
tests/unit/hooks/test_mongodb.py

@@ -72,7 +72,7 @@ def test_dump_databases_runs_mongodump_with_username_and_password():
             'name': 'foo',
             'username': 'mongo',
             'password': 'trustsome1',
-            'authentication_database': "admin",
+            'authentication_database': 'admin',
         }
     ]
     process = flexmock()

+ 49 - 20
tests/unit/hooks/test_ntfy.py

@@ -2,6 +2,7 @@ from enum import Enum
 
 from flexmock import flexmock
 
+import borgmatic.hooks.monitor
 from borgmatic.hooks import ntfy as module
 
 default_base_url = 'https://ntfy.sh'
@@ -37,12 +38,16 @@ def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
     hook_config = {'topic': topic}
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
 
 
@@ -54,12 +59,16 @@ def test_ping_monitor_with_auth_hits_hosted_ntfy_on_fail():
     }
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=module.requests.auth.HTTPBasicAuth('testuser', 'fakepassword'),
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
 
 
@@ -67,13 +76,17 @@ def test_ping_monitor_auth_with_no_username_warning():
     hook_config = {'topic': topic, 'password': 'fakepassword'}
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
     ).and_return(flexmock(ok=True)).once()
     flexmock(module.logger).should_receive('warning').once()
 
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
 
 
@@ -81,13 +94,17 @@ def test_ping_monitor_auth_with_no_password_warning():
     hook_config = {'topic': topic, 'username': 'testuser'}
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
     ).and_return(flexmock(ok=True)).once()
     flexmock(module.logger).should_receive('warning').once()
 
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
 
 
@@ -98,7 +115,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
     module.ping_monitor(
         hook_config,
         'config.yaml',
-        module.monitor.State.START,
+        borgmatic.hooks.monitor.State.START,
         monitoring_log_level=1,
         dry_run=False,
     )
@@ -111,7 +128,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
     module.ping_monitor(
         hook_config,
         'config.yaml',
-        module.monitor.State.FINISH,
+        borgmatic.hooks.monitor.State.FINISH,
         monitoring_log_level=1,
         dry_run=False,
     )
@@ -121,12 +138,16 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
     hook_config = {'topic': topic, 'server': custom_base_url}
     flexmock(module.requests).should_receive('post').with_args(
         f'{custom_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
 
 
@@ -135,7 +156,11 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
     flexmock(module.requests).should_receive('post').never()
 
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=True
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=True,
     )
 
 
@@ -146,7 +171,11 @@ def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
-        hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
+        hook_config,
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
     )
 
 
@@ -154,14 +183,14 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
     hook_config = {'topic': topic, 'states': ['start', 'fail']}
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.START),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.START),
         auth=None,
     ).and_return(flexmock(ok=True)).once()
 
     module.ping_monitor(
         hook_config,
         'config.yaml',
-        module.monitor.State.START,
+        borgmatic.hooks.monitor.State.START,
         monitoring_log_level=1,
         dry_run=False,
     )
@@ -171,7 +200,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
     hook_config = {'topic': topic}
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
     ).and_raise(module.requests.exceptions.ConnectionError)
     flexmock(module.logger).should_receive('warning').once()
@@ -179,7 +208,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
     module.ping_monitor(
         hook_config,
         'config.yaml',
-        module.monitor.State.FAIL,
+        borgmatic.hooks.monitor.State.FAIL,
         monitoring_log_level=1,
         dry_run=False,
     )
@@ -193,7 +222,7 @@ def test_ping_monitor_with_other_error_logs_warning():
     )
     flexmock(module.requests).should_receive('post').with_args(
         f'{default_base_url}/{topic}',
-        headers=return_default_message_headers(module.monitor.State.FAIL),
+        headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
         auth=None,
     ).and_return(response)
     flexmock(module.logger).should_receive('warning').once()
@@ -201,7 +230,7 @@ def test_ping_monitor_with_other_error_logs_warning():
     module.ping_monitor(
         hook_config,
         'config.yaml',
-        module.monitor.State.FAIL,
+        borgmatic.hooks.monitor.State.FAIL,
         monitoring_log_level=1,
         dry_run=False,
     )

+ 2 - 2
tox.ini

@@ -1,5 +1,5 @@
 [tox]
-envlist = py37,py38,py39,py310
+envlist = py37,py38,py39,py310,py311
 skip_missing_interpreters = True
 skipsdist = True
 minversion = 3.14.1
@@ -13,7 +13,7 @@ whitelist_externals =
 passenv = COVERAGE_FILE
 commands =
     pytest {posargs}
-    py38,py39,py310: black --check .
+    py38,py39,py310,py311: black --check .
     isort --check-only --settings-path setup.cfg .
     flake8 borgmatic tests