Compare commits

...
Author SHA1 Message Date
Dan Helfman afeaa86cbf Proof of concept for a helpful error when bootstrapping with multiple "latest" archives (#1310). 2026-05-22 19:32:45 -07:00
Dan Helfman 7e7c3c273e Update the Apprise monitoring hook's "url" option to support loading credentials with the "{credential ...}" syntax (#1308). 2026-05-22 15:25:42 -07:00
Dan Helfman 704db9c9d7 Add a "--quick-stats" flag and corresponding "quick_statistics" option (#1309). 2026-05-22 14:12:46 -07:00
Dan Helfman b6ab326bb4 Switch to PEP 639 license format. 2026-05-22 10:26:12 -07:00
Dan Helfman aaf5812a3f Cosmetic function argument ordering fix (#1256). 2026-05-12 22:13:05 -07:00
Dan Helfman 47a6691886 Upgrade dependencies. 2026-05-12 15:29:56 -07:00
Dan Helfman 596d59ef60 Fix a race condition in which borgmatic sometimes swallows Borg error output without logging it (#1256). 2026-05-12 15:28:31 -07:00
Dan Helfman 9839b3dada Fix end-to-end database tests (#1303). 2026-05-09 11:05:28 -07:00
Dan Helfman 411685280c For MariaDB/MySQL, dump events, routines, and tablespaces. For MariaDB, dump a subset of system data when dumping "mysql"/"all" (#1303). 2026-05-08 09:38:13 -07:00
Dan Helfman cce679248f Clarify "umask" option semantics in regards to integer and octal (#1305). 2026-05-06 14:57:43 -07:00
Dan Helfman 68c9516424 Upgrade Apprise dependency. 2026-05-02 19:08:24 -07:00
Dan Helfman a508cabe3f Update the KeePassXC credential hook to support KeePassXC's secret service integration. 2026-04-29 12:52:27 -07:00
Dan Helfman 0e1659bd73 Expand the "patterns_from" and "exclude_from" options to support paths containing tildes and globs (#1301). 2026-04-27 10:13:21 -07:00
Dan Helfman ad61ad356e Update the documentation to use Pagefind's component-based search UI. 2026-04-26 09:10:07 -07:00
Dan Helfman 5c7d03910b Fix the "source_directories_must_exist" option to support source directories relative to a "working_directory" (#1300). 2026-04-25 13:49:55 -07:00
Dan Helfman 468af1de0b Fix the PostgreSQL database hook to properly parse "*options" values containing quoted spaces. 2026-04-24 19:24:44 -07:00
Dan Helfman 524d8263a7 Upgrade dependencies. 2026-04-24 17:04:29 -07:00
Dan Helfman 1d2aea0951 Add a development script for upgrading pinned dependencies. 2026-04-24 11:54:37 -07:00
Dan Helfman 961ff7c724 For the MariaDB and MySQL database hooks, escape quotes in passwords when the "password_transport" option is "pipe". 2026-04-24 10:24:45 -07:00
Dan Helfman 3e80056956 Add projects.torsion.org reply by email to NEWS. 2026-04-20 21:52:22 -07:00
Dan Helfman d8f558ce0d Add the NEWS changelog file to release tarball (#1298). 2026-04-20 15:42:17 -07:00
Dan Helfman 9da75fdc58 Use pytest tmp_path fixture instead of hardcoded /tmp paths (#1296).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1296
2026-04-20 18:10:02 +00:00
Dan Helfman ac9c8bb644 Fix build of standalone binary. 2026-04-20 10:28:57 -07:00
Sirio Balmelli 0b34ef0e1a tests: use pytest tmp_path fixture instead of hardcoded /tmp paths
tmp_path is a built-in pytest fixture providing a unique per-test tempdir.

Hardcoded /tmp paths break when multiple builders run tests concurrently
(e.g. Nix parallel builds): leftover or colliding files cause spurious failures.

Signed-off-by: Sirio Balmelli <sirio@b-ad.ch>
2026-04-15 16:35:41 +02:00
49 changed files with 879 additions and 156 deletions
+1
View File
@@ -1,4 +1,5 @@
# This file only applies to the source dist tarball, not the built wheel.
include NEWS
include borgmatic/config/schema.yaml
graft docs
graft sample
+29
View File
@@ -1,3 +1,32 @@
2.1.6.dev0
* #1256: Fix a race condition in which borgmatic sometimes swallows Borg error output without
logging it.
* #1300: Fix the "source_directories_must_exist" option to support source directories relative to a
"working_directory".
* #1301: Expand the "patterns_from" and "exclude_from" options to support paths containing tildes
and globs.
* #1303: For the MariaDB and MySQL database hooks, include events, routines, and tablespaces when
dumping a database.
* #1303: For the MariaDB hook, include only a subset of system data when dumping the "mysql" system
database (or "all" databases), so the dump is actually restorable. See the documentation for more
information: https://torsion.org/borgmatic/reference/configuration/data-sources/mariadb/
* #1308: Update the Apprise monitoring hook's "url" option to support loading credentials with the
"{credential ...}" syntax. See the documentation for more information:
https://torsion.org/borgmatic/reference/configuration/credentials/
* #1309: Add a "--quick-stats" flag and corresponding "quick_statistics" option for showing only
abbreviated statistics for the "create" action. Borg 1.4.5+ only.
* Update the KeePassXC credential hook to support KeePassXC's secret service integration. See the
documentation for more information:
https://torsion.org/borgmatic/reference/configuration/credentials/keepassxc/
* Add the NEWS changelog file to release tarball (#1298).
* Enable reply by email on projects.torsion.org, so replies to notification emails get posted as
comments on tickets.
* For the MariaDB and MySQL database hooks, escape quotes in passwords when the
"password_transport" option is "pipe".
* Add a development script for upgrading pinned dependencies.
* Fix the PostgreSQL database hook to properly parse "*options" values containing quoted spaces.
* Update the documentation to use Pagefind's component-based search UI.
2.1.5
* #1229: Document the permissions needed for the PostgreSQL database hook:
https://torsion.org/borgmatic/reference/configuration/data-sources/postgresql/
+19
View File
@@ -0,0 +1,19 @@
.
apprise
attrs
certifi
charset-normalizer
click
idna
jsonschema
jsonschema-specifications
markdown
oauthlib
packaging
pyyaml
referencing
requests
requests-oauthlib
rpds-py
ruamel-yaml
urllib3
+21 -19
View File
@@ -1,19 +1,21 @@
.
apprise==1.9.9
attrs==26.1.0
certifi==2026.2.25
charset-normalizer==3.4.6
click==8.3.1
idna==3.11
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
markdown==3.10.2
oauthlib==3.3.1
packaging==26.0
pyyaml==6.0.3
referencing==0.37.0
requests==2.33.1
requests-oauthlib==2.0.0
rpds-py==0.30.0
ruamel-yaml==0.19.1
urllib3==2.6.3
# This file was autogenerated by uv via the following command:
# uv pip compile --annotation-style line binary_requirements.in -o binary_requirements.txt
apprise==1.10.0 # via -r binary_requirements.in
attrs==26.1.0 # via jsonschema, referencing, -r binary_requirements.in
. # via -r binary_requirements.in
certifi==2026.4.22 # via apprise, requests, -r binary_requirements.in
charset-normalizer==3.4.7 # via requests, -r binary_requirements.in
click==8.3.3 # via apprise, -r binary_requirements.in
idna==3.14 # via requests, -r binary_requirements.in
jsonschema==4.26.0 # via borgmatic, -r binary_requirements.in
jsonschema-specifications==2025.9.1 # via jsonschema, -r binary_requirements.in
markdown==3.10.2 # via apprise, -r binary_requirements.in
oauthlib==3.3.1 # via requests-oauthlib, -r binary_requirements.in
packaging==26.2 # via borgmatic, -r binary_requirements.in
pyyaml==6.0.3 # via apprise, -r binary_requirements.in
referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r binary_requirements.in
requests==2.34.0 # via apprise, borgmatic, requests-oauthlib, -r binary_requirements.in
requests-oauthlib==2.0.0 # via apprise, -r binary_requirements.in
rpds-py==0.30.0 # via jsonschema, referencing, -r binary_requirements.in
ruamel-yaml==0.19.1 # via borgmatic, -r binary_requirements.in
urllib3==2.7.0 # via requests, -r binary_requirements.in
+1 -1
View File
@@ -383,7 +383,7 @@ def collect_spot_check_source_paths(
# Omit "progress" because it interferes with "list_details".
config=dict(config, progress=False, list_details=True),
patterns=borgmatic.actions.pattern.process_patterns(
borgmatic.actions.pattern.collect_patterns(config)
borgmatic.actions.pattern.collect_patterns(config, working_directory)
+ tuple(
borgmatic.borg.pattern.Pattern(
config_path,
+37
View File
@@ -1,8 +1,12 @@
import argparse
import datetime
import itertools
import json
import logging
import os
import borgmatic.borg.extract
import borgmatic.borg.info
import borgmatic.borg.repo_list
import borgmatic.config.paths
@@ -100,6 +104,39 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
Raise CalledProcessError or OSError if Borg could not be run.
'''
config = make_bootstrap_config(bootstrap_arguments)
if bootstrap_arguments.archive == 'latest':
archives_data = json.loads(
borgmatic.borg.info.display_archives_info(
bootstrap_arguments.repository,
config,
local_borg_version,
info_arguments=argparse.Namespace(archive=None, prefix=None, json=True),
global_arguments=global_arguments,
local_path=bootstrap_arguments.local_path,
remote_path=bootstrap_arguments.remote_path,
)
)['archives']
get_repo_archive_format = lambda archive_data: archive_data['command_line'][-1]
get_archive_start = lambda archive_data: datetime.datetime.fromisoformat(
archive_data['start']
)
latest_archives = {
repo_archive_format: max(archives_data, key=get_archive_start)['name']
for repo_archive_format, archives_data in itertools.groupby(
sorted(archives_data, key=get_repo_archive_format),
key=get_repo_archive_format,
)
if not repo_archive_format.endswith('checkpoint')
}
if len(latest_archives) > 1:
raise ValueError(
f'The repository appears to have multiple "latest" archives, each with a different archive name format: {", ".join(sorted(latest_archives.values()))}. Please select one with --archive.'
)
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
bootstrap_arguments.repository,
bootstrap_arguments.archive,
+1 -1
View File
@@ -45,7 +45,7 @@ def run_create(
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
patterns = pattern.process_patterns(
pattern.collect_patterns(config),
pattern.collect_patterns(config, working_directory),
config,
working_directory,
borgmatic_runtime_directory,
+3 -2
View File
@@ -21,10 +21,11 @@ def run_diff(
# Only process patterns if only_patterns flag is set
if diff_arguments.only_patterns:
working_directory = borgmatic.config.paths.get_working_directory(config)
processed_patterns = borgmatic.actions.pattern.process_patterns(
(*borgmatic.actions.pattern.collect_patterns(config),),
(*borgmatic.actions.pattern.collect_patterns(config, working_directory),),
config,
borgmatic.config.paths.get_working_directory(config),
working_directory,
)
else:
processed_patterns = None
+8 -5
View File
@@ -34,10 +34,11 @@ def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_sty
)
def collect_patterns(config):
def collect_patterns(config, working_directory):
'''
Given a configuration dict, produce a single sequence of patterns comprised of the configured
source directories, patterns, excludes, pattern files, and exclude files.
Given a configuration dict and the working directory, produce a single sequence of patterns
comprised of the configured source directories, patterns, excludes, pattern files, and exclude
files.
The idea is that Borg has all these different ways of specifying includes, excludes, source
directories, etc., but we'd like to collapse them all down to one common format (patterns) for
@@ -68,7 +69,8 @@ def collect_patterns(config):
+ tuple(
parse_pattern(pattern_line.strip())
for filename in config.get('patterns_from', ())
for pattern_line in open(filename, encoding='utf-8')
for expanded_path in expand_directory(filename, working_directory)
for pattern_line in open(expanded_path, encoding='utf-8')
if not pattern_line.lstrip().startswith('#')
if pattern_line.strip()
)
@@ -78,7 +80,8 @@ def collect_patterns(config):
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for filename in config.get('exclude_from', ())
for exclude_line in open(filename, encoding='utf-8')
for expanded_path in expand_directory(filename, working_directory)
for exclude_line in open(expanded_path, encoding='utf-8')
if not exclude_line.lstrip().startswith('#')
if exclude_line.strip()
)
+4 -2
View File
@@ -30,10 +30,12 @@ def run_recreate(
else:
logger.answer(f'Recreating repository{dry_run_label}')
working_directory = borgmatic.config.paths.get_working_directory(config)
# Collect and process patterns.
processed_patterns = borgmatic.actions.pattern.process_patterns(
(
*borgmatic.actions.pattern.collect_patterns(config),
*borgmatic.actions.pattern.collect_patterns(config, working_directory),
# Also add borgmatic-specific paths, so they don't get excluded from the recreated
# archive. Note that this doesn't currently work for archives created with Borg 1.2 or
# below.
@@ -42,7 +44,7 @@ def run_recreate(
),
),
config,
borgmatic.config.paths.get_working_directory(config),
working_directory,
)
archive = borgmatic.borg.repo_list.resolve_archive_name(
+1 -1
View File
@@ -539,7 +539,7 @@ def run_restore(
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
patterns = borgmatic.actions.pattern.process_patterns(
borgmatic.actions.pattern.collect_patterns(config),
borgmatic.actions.pattern.collect_patterns(config, working_directory),
config,
working_directory,
)
+11 -2
View File
@@ -180,7 +180,9 @@ def make_base_create_command( # noqa: PLR0912
open pattern file handle).
'''
if config.get('source_directories_must_exist', True):
borgmatic.borg.pattern.check_all_root_patterns_exist(patterns)
borgmatic.borg.pattern.check_all_root_patterns_exist(
patterns, borgmatic.config.paths.get_working_directory(config)
)
patterns_file = borgmatic.borg.pattern.write_patterns_file(
patterns,
@@ -372,7 +374,9 @@ def create_archive(
if json:
output_log_level = None
elif config.get('list_details') or (config.get('statistics') and not dry_run):
elif config.get('list_details') or (
(config.get('statistics') or config.get('quick_statistics')) and not dry_run
):
output_log_level = logging.ANSWER
else:
output_log_level = logging.INFO
@@ -384,6 +388,11 @@ def create_archive(
create_flags += (
(('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ())
+ (('--stats',) if config.get('statistics') and not json and not dry_run else ())
+ (
('--quick-stats',)
if config.get('quick_statistics') and not json and not dry_run
else ()
)
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ())
+ (('--progress',) if config.get('progress') else ())
+ (('--json',) if json else ())
+4 -4
View File
@@ -88,16 +88,16 @@ def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=Non
return patterns_file
def check_all_root_patterns_exist(patterns):
def check_all_root_patterns_exist(patterns, working_directory):
'''
Given a sequence of Pattern instances, check that all root pattern paths exist. If any don't,
raise an exception.
Given a sequence of Pattern instances and the current working directory, check that all root
pattern paths exist. If any don't, raise an exception.
'''
missing_paths = [
pattern.path
for pattern in patterns
if pattern.type == Pattern_type.ROOT
if not os.path.exists(pattern.path)
if not os.path.exists(os.path.join(working_directory or '', pattern.path))
]
if missing_paths:
+7
View File
@@ -896,6 +896,13 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915
action='store_true',
help='Display statistics of archive',
)
create_group.add_argument(
'--quick-stats',
dest='quick_statistics',
default=None,
action='store_true',
help='Display statistics of archive, skipping repository-wide "All archives" and chunk index statistics [Borg 1.4.5+ only]',
)
create_group.add_argument(
'--list',
'--files',
+25 -3
View File
@@ -489,6 +489,9 @@ properties:
description: |
Umask used for when executing Borg or calling hooks. Defaults to
0077 for Borg or the umask that borgmatic is run with for hooks.
Even though this value is a YAML integer, borgmatic interprets it as
octal. YAML's "0o"-prefixed octal notation is not currently
supported.
example: 0077
lock_wait:
type: integer
@@ -1088,6 +1091,15 @@ properties:
Corresponds to the "--stats" flag on those actions. Defaults to
false.
example: true
quick_statistics:
type: boolean
description: |
Display statistics for an archive when running supported actions,
skipping the repository-wide "All archives" and chunk index
statistics to save some time. Corresponds to the "--quick-stats"
flag on those actions. Defaults to false. (This option is supported
for Borg 1.4.5+ only.)
example: true
list_details:
type: boolean
description: |
@@ -2822,7 +2834,9 @@ properties:
properties:
url:
type: string
description: URL of this Apprise service.
description: |
URL of this Apprise service. Supports the
"{credential ...}" syntax.
example: "gotify://hostname/token"
label:
type: string
@@ -3309,24 +3323,32 @@ properties:
description: |
Command to use instead of "keepassxc-cli".
example: /usr/local/bin/keepassxc-cli
secret_tool_command:
type: string
description: |
Command to use instead of "secret-tool".
example: /usr/local/bin/secret-tool
ask_for_password:
type: boolean
description: |
Whether keepassxc-cli should prompt the user for a password.
Disabling this is only really useful if you're unlocking
your KeePassXC database with a key file instead of a
password. Defaults to true.
password. Ignored when using KeePassXC's secret service
integration. Defaults to true.
example: false
key_file:
type: string
description: |
Path to a key file for unlocking the KeePassXC database.
Ignored when using KeePassXC's secret service integration.
example: /path/to/keyfile
yubikey:
type: string
description: |
YubiKey slot and optional serial number used to access the
KeePassXC database. The format is "<slot[:serial]>", where:
KeePassXC database. Ignored when using KeePassXC's secret
service integration. The format is "<slot[:serial]>", where:
* <slot> is the YubiKey slot number (e.g., `1` or `2`).
* <serial> (optional) is the YubiKey's serial number (e.g.,
`7370001`).
+31 -7
View File
@@ -372,13 +372,21 @@ def log_buffer_lines(
yield log_record.getMessage()
def raise_for_process_errors(buffer_readers, process_metadatas, borg_local_path, borg_exit_codes):
def raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level,
borg_local_path,
borg_exit_codes,
capture_stderr=False,
):
'''
Given a dict from buffer object to Buffer_reader, a dict from subprocess.Popen() instance to
Process_metadata instance, Borg's local path, a sequence of exit code configuration dicts, check
the given processes for error or warning exit codes. If found, vent or kill any running
processes. In the case of an error exit code, raise. In the case of warning, return
Exit_status.WARNING. Otherwise, return None.
Process_metadata instance, a requested output log level for stdout, Borg's local path, a
sequence of exit code configuration dicts, and whether to capture stderr, check the given
processes for error or warning exit codes. If found, vent or kill any running processes and
drain any remaining buffer lines. In the case of an error exit code, raise. In the case of
warning, return Exit_status.WARNING. Otherwise, return None.
'''
result_status = None
@@ -404,6 +412,13 @@ def raise_for_process_errors(buffer_readers, process_metadatas, borg_local_path,
result_status = Exit_status.WARNING
continue
# Drain and log remaining buffer lines, so no output gets lost. But swallow captured output,
# since we're raising below instead of yielding captured lines.
tuple(
log_remaining_buffer_lines(
buffer_readers, process_metadatas, output_log_level, borg_local_path, capture_stderr
)
)
last_lines = process_metadatas[process].last_lines
# If an error occurs, include its output in the raised exception so that we don't
@@ -429,6 +444,9 @@ def log_remaining_buffer_lines(
whether to capture stderr, drain and log any remaining output lines from the buffers until
they're empty. Additionally, for any log records with a log level the same as the output log
level, yield those log messages for capture.
The main difference between this function and log_buffer_lines() is that this one doesn't just
do one "turn of the crank" of logging buffer output; it completely drains any remaining output.
'''
for output_buffer, reader in buffer_readers.items():
if not reader.process:
@@ -446,6 +464,7 @@ def log_remaining_buffer_lines(
borg_local_path=borg_local_path,
command=reader.process.args,
),
last_lines=process_metadatas[reader.process].last_lines,
)
if (
@@ -494,7 +513,7 @@ def log_outputs(
for buffer in output_buffers_for_process(process, exclude_stdouts)
}
# Log output lines for each process until they all exit.
# Log output lines for each process until they all exit or one errors.
while True:
yield from log_buffer_lines(
buffer_readers, process_metadatas, output_log_level, borg_local_path, capture_stderr
@@ -502,7 +521,12 @@ def log_outputs(
if (
raise_for_process_errors(
buffer_readers, process_metadatas, borg_local_path, borg_exit_codes
buffer_readers,
process_metadatas,
output_log_level,
borg_local_path,
borg_exit_codes,
capture_stderr,
)
== Exit_status.WARNING
):
+17 -2
View File
@@ -7,19 +7,34 @@ import borgmatic.execute
logger = logging.getLogger(__name__)
SECRET_SERVICE_DATABASE_PATH = 'secret-service'
def load_credential(hook_config, config, credential_parameters):
'''
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
containing a KeePassXC database path and an attribute name to load, run keepassxc-cli to fetch
the corresponding KeePassXC credential and return it.
the corresponding KeePassXC credential and return it. Or use secret-tool if the database path
is "secret-service", indicating that KeePassXC's secret service integration should be used
instead.
Raise ValueError if keepassxc-cli can't retrieve the credential.
Raise ValueError if keepassxc-cli or secret-tool can't retrieve the credential.
'''
try:
(database_path, attribute_name) = credential_parameters
except ValueError:
raise ValueError(f'Invalid KeePassXC credential parameters: {credential_parameters}')
if database_path == SECRET_SERVICE_DATABASE_PATH:
command = (
*shlex.split((hook_config or {}).get('secret_tool_command', 'secret-tool')),
*('lookup', 'Path', attribute_name),
)
return '\n'.join(borgmatic.execute.execute_command_and_capture_output(command)).rstrip(
os.linesep
)
expanded_database_path = os.path.expanduser(database_path)
if not os.path.exists(expanded_database_path):
+18 -10
View File
@@ -1,4 +1,3 @@
import copy
import logging
import os
import re
@@ -67,7 +66,9 @@ def make_defaults_file_options(username=None, password=None, defaults_extra_file
Do not use the returned value for multiple different command invocations. That will not work
because each pipe is "used up" once read.
'''
escaped_password = None if password is None else password.replace('\\', '\\\\')
escaped_password = (
None if password is None else password.replace('\\', '\\\\').replace('"', '\\"')
)
values = '\n'.join(
(
@@ -106,6 +107,10 @@ def make_defaults_file_options(username=None, password=None, defaults_extra_file
return (f'--defaults-extra-file=/dev/fd/{read_file_descriptor}',)
EXCLUDED_SYSTEM_DATABASE_NAMES = ('information_schema', 'performance_schema', 'sys')
SYSTEM_DATABASE_NAME = 'mysql'
def database_names_to_dump(database, config, username, password, environment, dry_run):
'''
Given a requested database config, a configuration dict, a database username and password, an
@@ -164,17 +169,18 @@ def database_names_to_dump(database, config, username, password, environment, dr
working_directory=borgmatic.config.paths.get_working_directory(config),
)
# Dumping system databases directly doesn't work; too much gets dumped (including virtual tables
# that MariaDB recreates on startup) and so the dump isn't restorable. Therefore several system
# databases are excluded here. But also see below where select system tables from the "mysql"
# system table are dumped via the "--system" flag.
return tuple(
show_name.strip()
for show_name in show_lines
if show_name not in SYSTEM_DATABASE_NAMES
if show_name not in EXCLUDED_SYSTEM_DATABASE_NAMES
if not skip_names or show_name not in skip_names
)
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
def execute_dump_command(
database,
config,
@@ -235,8 +241,9 @@ def execute_dump_command(
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if database.get('tls') is True else ())
+ (('--skip-ssl',) if database.get('tls') is False else ())
+ ('--databases',)
+ database_names
+ ('--databases', '--events', '--routines', '--all-tablespaces')
+ (('--system=users,udfs,servers',) if SYSTEM_DATABASE_NAME in database_names else ())
+ tuple(name for name in database_names if name != SYSTEM_DATABASE_NAME)
+ ('--result-file', dump_filename)
)
@@ -323,6 +330,7 @@ def dump_data_sources(
raise ValueError('Cannot find any MariaDB databases to dump.')
# Database dumps to individual files.
if database['name'] == 'all' and database.get('format'):
for database_name in dump_database_names:
dumps_metadata.append(
@@ -335,8 +343,7 @@ def dump_data_sources(
database.get('container'),
)
)
renamed_database = copy.copy(database)
renamed_database['name'] = database_name
renamed_database = dict(database, name=database_name)
processes.append(
execute_dump_command(
renamed_database,
@@ -350,6 +357,7 @@ def dump_data_sources(
dry_run_label,
),
)
# Database dumps all to one file.
else:
dumps_metadata.append(
borgmatic.actions.restore.Dump(
+2 -4
View File
@@ -1,4 +1,3 @@
import copy
import logging
import os
import shlex
@@ -166,7 +165,7 @@ def execute_dump_command(
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if database.get('tls') is True else ())
+ (('--skip-ssl',) if database.get('tls') is False else ())
+ ('--databases',)
+ ('--databases', '--events', '--routines', '--all-tablespaces')
+ database_names
+ ('--result-file', dump_filename)
)
@@ -266,8 +265,7 @@ def dump_data_sources(
database.get('container'),
)
)
renamed_database = copy.copy(database)
renamed_database['name'] = database_name
renamed_database = dict(database, name=database_name)
processes.append(
execute_dump_command(
renamed_database,
+8 -4
View File
@@ -100,7 +100,11 @@ def database_names_to_dump(database, config, environment, dry_run):
if 'username' in database
else ()
)
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
+ (
tuple(shlex.quote(part) for part in shlex.split(database['list_options']))
if 'list_options' in database
else ()
)
)
logger.debug('Querying for "all" PostgreSQL databases to dump')
list_lines = execute_command_and_capture_output(
@@ -226,7 +230,7 @@ def dump_data_sources(
+ (('--compress', shlex.quote(str(compression))) if compression is not None else ())
+ (('--file', shlex.quote(dump_filename)) if dump_format == 'directory' else ())
+ (
tuple(shlex.quote(option) for option in database['options'].split(' '))
tuple(shlex.quote(part) for part in shlex.split(database['options']))
if 'options' in database
else ()
)
@@ -393,7 +397,7 @@ def restore_data_source_dump(
+ (('--username', username) if username else ())
+ (('--dbname', data_source['name']) if not all_databases else ())
+ (
tuple(data_source['analyze_options'].split(' '))
tuple(shlex.quote(part) for part in shlex.split(data_source['analyze_options']))
if 'analyze_options' in data_source
else ()
)
@@ -414,7 +418,7 @@ def restore_data_source_dump(
+ (('--username', username) if username else ())
+ (('--no-owner',) if data_source.get('no_owner', False) else ())
+ (
tuple(data_source['restore_options'].split(' '))
tuple(shlex.quote(part) for part in shlex.split(data_source['restore_options']))
if 'restore_options' in data_source
else ()
)
+7 -1
View File
@@ -1,6 +1,7 @@
import logging
import operator
import borgmatic.hooks.credential.parse
import borgmatic.hooks.monitoring.logs
import borgmatic.hooks.monitoring.monitor
@@ -76,7 +77,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
logger.info(f'Pinging Apprise services: {labels_string}{dry_run_string}')
apprise_object = apprise.Apprise()
apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services'))))
apprise_object.add(
[
borgmatic.hooks.credential.parse.resolve_credential(service['url'], config)
for service in hook_config.get('services')
]
)
if dry_run:
return
+1
View File
@@ -23,6 +23,7 @@
{% if feedTitle and feedUrl %}
<link rel="alternate" href="{{ feedUrl }}" title="{{ feedTitle }}" type="application/atom+xml">
{% endif %}
{{ head_additions | safe }}
</head>
<body>
@@ -43,14 +43,15 @@ postgresql_databases:
```
### Custom command
### Custom commands
You can also optionally override the `keepassxc-cli` command that borgmatic calls to load
passwords:
You can also optionally override the `keepassxc-cli` or `secret-tool` commands
that borgmatic call to load passwords:
```yaml
keepassxc:
keepassxc_cli_command: /usr/local/bin/keepassxc-cli
secret_tool_command: /usr/local/bin/secret-tool
```
Another example:
@@ -98,3 +99,30 @@ keepassxc:
The value here is the YubiKey slot number (e.g., `1` or `2`) and optional serial
number (e.g., `7370001`) used to access the KeePassXC database. Join the two
values with a colon, but omit the colon if you're leaving out the serial number.
### Secret service integration
<span class="minilink minilink-addedin">New in version 2.1.6</span> borgmatic
supports [KeePassXC's secret service
integration](https://keepassxc.org/docs/KeePassXC_UserGuide#_secret_service_integration)
that integrates with the [freedesktop secret service
API](https://specifications.freedesktop.org/secret-service/latest/) and allows
clients like borgmatic to access your passwords.
To use this feature from borgmatic, specify `secret-service` instead of a
KeePassXC database path when calling this credential hook. For instance:
```yaml
encryption_passphrase: "{credential keepassxc secret-service borgmatic}"
```
With this in place, borgmatic runs libsecret's `secret-tool` command to retrieve
the password titled "borgmatic" on demand. KeePassXC may then prompt you to
approve the password access, depending on how you've configured it.
One benefit of using the KeePassXC's secret service integration like this is
that you don't have to type a KeePassXC database passhprase (or use a keyfile)
whenever borgmatic accesses your passwords. Instead, you can configure
KeePassXC to prompt you to approve or deny each access. Or you can even
pre-approve password access to support automated borgmatic runs.
@@ -16,10 +16,46 @@ mariadb_databases:
```
### Full configuration
## System databases
<span class="minilink minilink-addedin">New in version 2.1.6</span> When dumping
["all"
databases](https://torsion.org/borgmatic/how-to/backup-your-databases/#all-databases),
borgmatic excludes most data coming from [MariaDB system
databases](https://mariadb.com/docs/server/reference/system-tables),
because much of it is populated on MariaDB startup and thus not restorable (or
just unnecessary to backup). The system data that borgmatic does include in
these dumps are: users, roles, grants, user-defined functions, and remote
servers.
Within a Borg archive, you can find this data stored in a dump named `mysql`—the
name of the system table this data comes from. And if you'd like to dump this
data without having to dump "all" databases, then you can configure a database
named `mysql` in your borgmatic configuration. For example:
```yaml
mariadb_databases:
- name: mysql
```
Even in this case though, only the subset of system data described above is
included in the dump.
<span class="minilink minilink-addedin">Prior to version 2.1.6</span> Dumps of
"all" databases excluded system databases and all of their data. Additionally,
explicitly dumping `mysql` was treated like any other database—and thus wasn't
easily restorable.
## Full configuration
{% include snippet/configuration/sample.md %}
```yaml
{% include borgmatic/mariadb_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -14,10 +14,15 @@ mongodb_databases:
```
### Full configuration
## Full configuration
{% include snippet/configuration/sample.md %}
```yaml
{% include borgmatic/mongodb_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -21,3 +21,8 @@ mysql_databases:
```yaml
{% include borgmatic/mysql_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -76,3 +76,8 @@ roles](https://www.postgresql.org/docs/current/predefined-roles.html) and
```yaml
{% include borgmatic/postgresql_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -22,3 +22,8 @@ sqlite_databases:
```yaml
{% include borgmatic/sqlite_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -5,9 +5,9 @@ eleventyNavigation:
parent: 🚨 Monitoring
---
<span class="minilink minilink-addedin">New in version 1.8.4</span>
[Apprise](https://github.com/caronc/apprise/wiki) is a local notification library
[Apprise](https://appriseit.com/) is a local notification library
that "allows you to send a notification to almost all of the most popular
[notification services](https://github.com/caronc/apprise/wiki) available to
[notification services](https://appriseit.com/services/) available to
us today such as: Telegram, Discord, Slack, Amazon SNS, Gotify, etc."
Depending on how you installed borgmatic, it may not have come with Apprise.
@@ -22,7 +22,7 @@ sudo uv tool install borgmatic[Apprise]
Omit `sudo` if borgmatic is installed as a non-root user.
Once Apprise is installed, configure borgmatic to notify one or more [Apprise
services](https://github.com/caronc/apprise/wiki). For example:
services](https://appriseit.com/services/). For example:
```yaml
apprise:
+40 -6
View File
@@ -1,14 +1,48 @@
---
title: Search the documentation
eleventyExcludeFromCollections: true
templateEngineOverride: md
head_additions: >
<link href="/borgmatic/pagefind/pagefind-component-ui.css" rel="stylesheet">
<script src="/borgmatic/pagefind/pagefind-component-ui.js" type="module"></script>
<style>
@media (prefers-color-scheme: light) {
:root {
--pf-summary-font-size: 14px;
--pf-result-title-font-size: 16px;
--pf-result-excerpt-font-size: 15px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--pf-summary-font-size: 14px;
--pf-result-title-font-size: 16px;
--pf-result-excerpt-font-size: 15px;
}
}
.pf-result .pf-result-title, .pf-heading-link {
font-weight: 600 !important;
}
.pf-result .pf-result-excerpt mark, .pf-heading-excerpt mark {
background-color: yellow !important;
}
</style>
---
<link href="/borgmatic/pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/borgmatic/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script>
<pagefind-config bundle-path="/borgmatic/pagefind/" base-url="/borgmatic/"></pagefind-config>
<p><pagefind-input placeholder="Search"></pagefind-input></p>
<p><pagefind-summary></pagefind-summary></p>
<p><pagefind-results></pagefind-results></p>
<script type="module">
const manager = window.PagefindComponents.getInstanceManager();
const instance = manager.getInstance('default');
window.addEventListener('DOMContentLoaded', (event) => {
let search = new PagefindUI({ element: '#search', showSubResults: true, autofocus: true });
let url_parameters = new URLSearchParams(window.location.search);
search.triggerSearch(url_parameters.get('query'));
instance.triggerSearch(url_parameters.get('query'));
});
</script>
+3 -2
View File
@@ -1,17 +1,18 @@
[project]
name = "borgmatic"
version = "2.1.5"
version = "2.1.6.dev0"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
description = "Simple, configuration-driven backup software for servers and workstations"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
readme = "README.md"
requires-python = ">=3.9"
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Programming Language :: Python",
"Topic :: Security :: Cryptography",
"Topic :: System :: Archiving :: Backup",
+2 -2
View File
@@ -44,9 +44,9 @@ scripts/export-docs-from-image
docs_path=dist/borgmatic-docs.tar.gz
# Build stand-alone binary.
uv venv --clear binary
uv venv --python 3.13 --clear binary
source binary/bin/activate
uv pip install -r binary_requirements.txt
uv pip install -r binary_requirements.txt nuitka[onefile]
nuitka --mode=onefile --enable-plugin=upx --include-package-data=borgmatic --include-data-dir=borgmatic.egg-info=borgmatic.egg-info --include-package=borgmatic.hooks --include-package=apprise --no-deployment-flag=self-execution borgmatic/commands/borgmatic.py
deactivate
rm -fr binary borgmatic.build borgmatic.dist
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
set -eou pipefail
uv pip compile --quiet --upgrade --annotation-style line test_requirements.in -o test_requirements.txt
uv pip compile --quiet --upgrade --annotation-style line binary_requirements.in -o binary_requirements.txt
# Carry forward certain packages with version ranges instead of absolute pins. This is because these
# packages are a pain to compile, and pre-built wheels available on pypi may not be available for
# pinned versions on particular platforms.
while IFS= read -r package_line; do
package_name=$(echo "$package_line" | cut -d ">" -f 1)
sed -i "s/^$package_name==.*/$package_line/" test_requirements.txt
done <<< $(grep ">" test_requirements.in)
sed -i "s/was autogenerated/was (mostly) autogenerated/" test_requirements.txt
+28
View File
@@ -0,0 +1,28 @@
apprise
attrs
certifi
charset-normalizer
click>=8.1.8
codespell
coverage
flexmock
idna
iniconfig
jsonschema
jsonschema-specifications
markdown
oauthlib
packaging
pluggy
pygments
pytest
pytest-cov
pytest-timeout
pyyaml>5.0.0
referencing
requests
requests-oauthlib
rpds-py
ruamel-yaml>0.15.0
typing-extensions
urllib3
+27 -25
View File
@@ -1,28 +1,30 @@
apprise==1.9.9
attrs==26.1.0
certifi==2026.2.25
charset-normalizer==3.4.6
# This file was (mostly) autogenerated by uv via the following command:
# uv pip compile --annotation-style line test_requirements.in -o test_requirements.txt
apprise==1.10.0 # via -r test_requirements.in
attrs==26.1.0 # via jsonschema, referencing, -r test_requirements.in
certifi==2026.4.22 # via apprise, requests, -r test_requirements.in
charset-normalizer==3.4.7 # via requests, -r test_requirements.in
click>=8.1.8
codespell==2.4.2
coverage==7.13.5
flexmock==0.13.0
idna==3.11
iniconfig==2.3.0
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
markdown==3.10.2
oauthlib==3.3.1
packaging==26.0
pluggy==1.6.0
pygments==2.20.0
pytest==9.0.2
pytest-cov==7.1.0
pytest-timeout==2.4.0
codespell==2.4.2 # via -r test_requirements.in
coverage==7.14.0 # via pytest-cov, -r test_requirements.in
flexmock==0.13.0 # via -r test_requirements.in
idna==3.14 # via requests, -r test_requirements.in
iniconfig==2.3.0 # via pytest, -r test_requirements.in
jsonschema==4.26.0 # via -r test_requirements.in
jsonschema-specifications==2025.9.1 # via jsonschema, -r test_requirements.in
markdown==3.10.2 # via apprise, -r test_requirements.in
oauthlib==3.3.1 # via requests-oauthlib, -r test_requirements.in
packaging==26.2 # via pytest, -r test_requirements.in
pluggy==1.6.0 # via pytest, pytest-cov, -r test_requirements.in
pygments==2.20.0 # via pytest, -r test_requirements.in
pytest==9.0.3 # via pytest-cov, pytest-timeout, -r test_requirements.in
pytest-cov==7.1.0 # via -r test_requirements.in
pytest-timeout==2.4.0 # via -r test_requirements.in
pyyaml>5.0.0
referencing==0.37.0
requests==2.33.1
requests-oauthlib==2.0.0
rpds-py==0.30.0
referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r test_requirements.in
requests==2.34.0 # via apprise, requests-oauthlib, -r test_requirements.in
requests-oauthlib==2.0.0 # via apprise, -r test_requirements.in
rpds-py==0.30.0 # via jsonschema, referencing, -r test_requirements.in
ruamel-yaml>0.15.0
typing-extensions==4.15.0
urllib3==2.6.3
typing-extensions==4.15.0 # via -r test_requirements.in
urllib3==2.7.0 # via requests, -r test_requirements.in
@@ -57,11 +57,15 @@ mariadb_databases:
hostname: mariadb
username: root
password: test
# Needed for restoring users to the system database, because we're not actually dropping
# users first. (Dropping root and mariadb.sys would cause problems.)
restore_options: --force
- name: all
{mariadb_mysql_dump_format_option}
hostname: mariadb
username: root
password: test
restore_options: --force
mysql_databases:
- name: test
hostname: not-actually-mysql
@@ -132,6 +136,7 @@ mariadb_databases:
restore_port: 3307
restore_username: root
restore_password: test2
restore_options: --force
mysql_databases:
- name: test
container: not-actually-mysql
@@ -201,6 +206,7 @@ mariadb_databases:
restore_port: 3307
restore_username: root
restore_password: test2
restore_options: --force
mysql_databases:
- name: test
hostname: not-actually-mysql
+2 -2
View File
@@ -315,10 +315,10 @@ def test_write_configuration_with_already_existing_file_raises():
module.write_configuration('config.yaml', 'config: yaml')
def test_write_configuration_with_already_existing_file_and_overwrite_does_not_raise():
def test_write_configuration_with_already_existing_file_and_overwrite_does_not_raise(tmp_path):
flexmock(os.path).should_receive('exists').and_return(True)
module.write_configuration('/tmp/config.yaml', 'config: yaml', overwrite=True)
module.write_configuration(str(tmp_path / 'config.yaml'), 'config: yaml', overwrite=True)
def test_write_configuration_with_already_existing_directory_does_not_raise():
+3 -3
View File
@@ -222,11 +222,11 @@ def test_parse_configuration_merges_include():
assert logs == []
def test_parse_configuration_raises_for_missing_config_file():
def test_parse_configuration_raises_for_missing_config_file(tmp_path):
with pytest.raises(FileNotFoundError):
module.parse_configuration(
'/tmp/config.yaml',
'/tmp/schema.yaml',
str(tmp_path / 'nonexistent' / 'config.yaml'),
str(tmp_path / 'nonexistent' / 'schema.yaml'),
arguments={'global': flexmock()},
)
+37
View File
@@ -557,6 +557,43 @@ def test_log_outputs_with_unfinished_process_re_polls():
)
def test_log_outputs_includes_error_output_when_output_spans_multiple_chunks():
flexmock(module.logger).should_receive('log')
flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.ERROR)
flexmock(module).should_receive('command_for_process').and_return('python')
process = subprocess.Popen(
[
sys.executable,
'-c',
(
'import os, sys; '
f'os.write(sys.stdout.fileno(), b"x" * {module.READ_CHUNK_SIZE + 10}); '
'os.write(sys.stdout.fileno(), b"\\nERROR: critical failure"); '
'os.close(1); '
'os._exit(2)'
),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
flexmock(module).should_receive('output_buffers_for_process').and_return((process.stdout,))
with pytest.raises(subprocess.CalledProcessError) as error:
tuple(
module.log_outputs(
(process,),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
borg_exit_codes=None,
)
)
assert error.value.output
assert 'ERROR: critical failure' in error.value.output
def test_read_lines_uses_system_locale_when_decoding_output():
flexmock(module.locale).should_receive('getpreferredencoding').and_return('ISO-8859-1')
process = subprocess.Popen(['echo', b'\xc4pple'], stdout=subprocess.PIPE)
+29 -7
View File
@@ -35,7 +35,7 @@ def test_parse_pattern_with_invalid_pattern_line_errors():
def test_collect_patterns_converts_source_directories():
assert module.collect_patterns({'source_directories': ['/foo', '/bar']}) == (
assert module.collect_patterns({'source_directories': ['/foo', '/bar']}, '/working') == (
Pattern('/foo', source=Pattern_source.CONFIG),
Pattern('/bar', source=Pattern_source.CONFIG),
)
@@ -48,14 +48,18 @@ def test_collect_patterns_parses_config_patterns():
flexmock(module).should_receive('parse_pattern').with_args(' ').never()
flexmock(module).should_receive('parse_pattern').with_args('R /bar').and_return(Pattern('/bar'))
assert module.collect_patterns({'patterns': ['R /foo', '# comment', '', ' ', 'R /bar']}) == (
assert module.collect_patterns(
{'patterns': ['R /foo', '# comment', '', ' ', 'R /bar']}, '/working'
) == (
Pattern('/foo'),
Pattern('/bar'),
)
def test_collect_patterns_converts_exclude_patterns():
assert module.collect_patterns({'exclude_patterns': ['/foo', '/bar', 'sh:**/baz']}) == (
assert module.collect_patterns(
{'exclude_patterns': ['/foo', '/bar', 'sh:**/baz']}, '/working'
) == (
Pattern(
'/foo',
Pattern_type.NO_RECURSE,
@@ -78,6 +82,12 @@ def test_collect_patterns_converts_exclude_patterns():
def test_collect_patterns_reads_config_patterns_from_file():
flexmock(module).should_receive('expand_directory').with_args(
'file1.txt', '/working'
).and_return(['file1.txt'])
flexmock(module).should_receive('expand_directory').with_args(
'file2.txt', '/working'
).and_return(['file2.txt'])
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('file1.txt', encoding='utf-8').and_return(
io.StringIO('R /foo')
@@ -92,7 +102,7 @@ def test_collect_patterns_reads_config_patterns_from_file():
flexmock(module).should_receive('parse_pattern').with_args('R /bar').and_return(Pattern('/bar'))
flexmock(module).should_receive('parse_pattern').with_args('R /baz').and_return(Pattern('/baz'))
assert module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']}) == (
assert module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']}, '/working') == (
Pattern('/foo'),
Pattern('/bar'),
Pattern('/baz'),
@@ -101,16 +111,25 @@ def test_collect_patterns_reads_config_patterns_from_file():
def test_collect_patterns_errors_on_missing_config_patterns_from_file():
builtins = flexmock(sys.modules['builtins'])
flexmock(module).should_receive('expand_directory').with_args(
'file1.txt', '/working'
).and_return(['file1.txt'])
builtins.should_receive('open').with_args('file1.txt', encoding='utf-8').and_raise(
FileNotFoundError
)
flexmock(module).should_receive('parse_pattern').never()
with pytest.raises(ValueError):
module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']})
module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']}, '/working')
def test_collect_patterns_reads_config_exclude_from_file():
flexmock(module).should_receive('expand_directory').with_args(
'file1.txt', '/working'
).and_return(['file1.txt'])
flexmock(module).should_receive('expand_directory').with_args(
'file2.txt', '/working'
).and_return(['file2.txt'])
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('file1.txt', encoding='utf-8').and_return(
io.StringIO('/foo')
@@ -134,7 +153,7 @@ def test_collect_patterns_reads_config_exclude_from_file():
default_style=Pattern_style.FNMATCH,
).and_return(Pattern('/baz', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH))
assert module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}) == (
assert module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}, '/working') == (
Pattern('/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
Pattern('/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
Pattern('/baz', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
@@ -142,12 +161,15 @@ def test_collect_patterns_reads_config_exclude_from_file():
def test_collect_patterns_errors_on_missing_config_exclude_from_file():
flexmock(module).should_receive('expand_directory').with_args(
'file1.txt', '/working'
).and_return(['file1.txt'])
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('file1.txt', encoding='utf-8').and_raise(OSError)
flexmock(module).should_receive('parse_pattern').never()
with pytest.raises(ValueError):
module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']})
module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}, '/working')
def test_expand_directory_with_basic_path_passes_it_through():
+74
View File
@@ -1477,6 +1477,44 @@ def test_create_archive_with_stats_and_dry_run_calls_borg_without_stats():
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
'statistics': True,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
global_arguments=flexmock(),
borgmatic_runtime_directory='/borgmatic/run',
)
def test_create_archive_with_quick_stats_and_dry_run_calls_borg_without_quick_stats():
# --dry-run and --quick-stats are mutually exclusive, see:
# https://borgbackup.readthedocs.io/en/stable/usage/create.html#description
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module).should_receive('make_base_create_command').and_return(
(('borg', 'create', '--dry-run'), REPO_ARCHIVE, flexmock()),
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--dry-run', '--info', *REPO_ARCHIVE),
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
borg_exit_codes=None,
working_directory=None,
environment=None,
)
insert_logging_mock(logging.INFO)
module.create_archive(
dry_run=True,
repository_path='repo',
config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
'quick_statistics': True,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
@@ -1806,6 +1844,42 @@ def test_create_archive_with_stats_and_json_calls_borg_without_stats_flag():
'source_directories': ['foo*'],
'repositories': ['repo'],
'exclude_patterns': None,
'statistics': True,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
global_arguments=flexmock(),
borgmatic_runtime_directory='/borgmatic/run',
json=True,
)
assert json_output == '[]'
def test_create_archive_with_quick_stats_and_json_calls_borg_without_quick_stats_flag():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module).should_receive('make_base_create_command').and_return(
(('borg', 'create'), REPO_ARCHIVE, flexmock()),
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'create', '--json', *REPO_ARCHIVE),
working_directory=None,
environment=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_yield('[]')
json_output = module.create_archive(
dry_run=False,
repository_path='repo',
config={
'source_directories': ['foo*'],
'repositories': ['repo'],
'exclude_patterns': None,
'quick_statistics': True,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
+21 -4
View File
@@ -34,19 +34,36 @@ def test_write_patterns_file_appends_to_existing():
def test_check_all_root_patterns_exist_with_existent_pattern_path_does_not_raise():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('exists').with_args('foo').and_return(True)
module.check_all_root_patterns_exist([Pattern('foo')])
module.check_all_root_patterns_exist([Pattern('foo')], working_directory=None)
def test_check_all_root_patterns_exist_with_existent_relative_pattern_path_and_working_directory_does_not_raise():
flexmock(module.os.path).should_receive('exists').with_args('foo').never()
flexmock(module.os.path).should_receive('exists').with_args('/working/foo').and_return(True)
module.check_all_root_patterns_exist([Pattern('foo')], working_directory='/working')
def test_check_all_root_patterns_exist_with_non_root_pattern_skips_existence_check():
flexmock(module.os.path).should_receive('exists').never()
module.check_all_root_patterns_exist([Pattern('foo', Pattern_type.INCLUDE)])
module.check_all_root_patterns_exist(
[Pattern('foo', Pattern_type.INCLUDE)], working_directory=None
)
def test_check_all_root_patterns_exist_with_non_existent_pattern_path_raises():
flexmock(module.os.path).should_receive('exists').and_return(False)
with pytest.raises(ValueError):
module.check_all_root_patterns_exist([Pattern('foo')])
module.check_all_root_patterns_exist([Pattern('foo')], working_directory=None)
def test_check_all_root_patterns_exist_with_non_existent_relative_pattern_path_and_working_directory_raises():
flexmock(module.os.path).should_receive('exists').with_args('foo').never()
flexmock(module.os.path).should_receive('exists').with_args('/working/foo').and_return(False)
with pytest.raises(ValueError):
module.check_all_root_patterns_exist([Pattern('foo')], working_directory='/working')
@@ -31,6 +31,55 @@ def test_load_credential_with_missing_database_raises():
)
def test_load_credential_with_secret_service_database_path_fetches_password_via_secret_tool():
flexmock(module.os.path).should_receive('expanduser').never()
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(
(
'secret-tool',
'lookup',
'Path',
'mypassword',
),
).and_yield('password').once()
assert (
module.load_credential(
hook_config={},
config={},
credential_parameters=('secret-service', 'mypassword'),
)
== 'password'
)
def test_load_credential_with_secret_service_database_path_and_secret_tool_command_calls_it():
flexmock(module.os.path).should_receive('expanduser').never()
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(
(
'/usr/local/bin/secret-tool',
'--some-option',
'lookup',
'Path',
'mypassword',
),
).and_yield('password').once()
assert (
module.load_credential(
hook_config={'secret_tool_command': '/usr/local/bin/secret-tool --some-option'},
config={},
credential_parameters=('secret-service', 'mypassword'),
)
== 'password'
)
def test_load_credential_with_present_database_fetches_password_from_keepassxc():
flexmock(module.os.path).should_receive('expanduser').with_args('database.kdbx').and_return(
'database.kdbx',
+102 -6
View File
@@ -64,6 +64,23 @@ def test_make_defaults_file_escapes_password_containing_backslash():
)
def test_make_defaults_file_escapes_password_containing_quote():
read_descriptor = 99
write_descriptor = flexmock()
flexmock(module.os).should_receive('pipe').and_return(read_descriptor, write_descriptor)
flexmock(module.os).should_receive('write').with_args(
write_descriptor,
b'[client]\nuser=root\n' + rb'password="trust\"some1"',
).once()
flexmock(module.os).should_receive('close')
flexmock(module.os).should_receive('set_inheritable')
assert module.make_defaults_file_options(username='root', password=r'trust"some1') == (
'--defaults-extra-file=/dev/fd/99',
)
def test_make_defaults_file_pipe_with_only_username_writes_it_to_file_descriptor():
read_descriptor = 99
write_descriptor = flexmock()
@@ -201,7 +218,7 @@ def test_database_names_to_dump_queries_mariadb_for_database_names():
),
environment=environment,
working_directory='/path/to/working/dir',
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all'},
@@ -239,7 +256,7 @@ def test_database_names_to_dump_with_database_name_all_and_skip_names_filters_ou
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'baz', 'mysql').once()
).and_yield('foo', 'bar', 'baz').once()
names = module.database_names_to_dump(
{'name': 'all', 'skip_names': ('foo', 'bar')},
@@ -279,7 +296,7 @@ def test_database_names_to_dump_runs_mariadb_with_socket_path():
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all', 'socket_path': '/socket'},
@@ -314,7 +331,7 @@ def test_database_names_to_dump_with_environment_password_transport_skips_defaul
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all', 'password_transport': 'environment'},
@@ -353,7 +370,7 @@ def test_database_names_to_dump_runs_mariadb_with_tls():
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all', 'tls': True},
@@ -392,7 +409,7 @@ def test_database_names_to_dump_runs_mariadb_without_tls():
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all', 'tls': False},
@@ -839,6 +856,9 @@ def test_execute_dump_command_runs_mariadb_dump():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -864,6 +884,58 @@ def test_execute_dump_command_runs_mariadb_dump():
)
def test_execute_dump_command_substitutes_system_flag_for_system_database_name():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
flexmock(module).should_receive('parse_extra_options').and_return((), None)
flexmock(module.database_config).should_receive('resolve_database_option').and_return(None)
flexmock(module).should_receive('make_defaults_file_options').with_args(
'root',
'trustsome1',
None,
).and_return(('--defaults-extra-file=/dev/fd/99',))
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command').with_args(
(
'mariadb-dump',
'--defaults-extra-file=/dev/fd/99',
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'--system=users,udfs,servers',
'--result-file',
'dump',
),
environment=None,
run_to_completion=False,
working_directory=None,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'mysql'},
config={},
username='root',
password='trustsome1',
dump_path=flexmock(),
database_names=('mysql',),
environment=None,
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
@@ -885,6 +957,9 @@ def test_execute_dump_command_with_environment_password_transport_skips_defaults
'--user',
'root',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -933,6 +1008,9 @@ def test_execute_dump_command_runs_mariadb_dump_without_add_drop_database():
'--defaults-extra-file=/dev/fd/99',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -990,6 +1068,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_hostname_and_port():
'--protocol',
'tcp',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1040,6 +1121,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_tls():
'--single-transaction',
'--ssl',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1090,6 +1174,9 @@ def test_execute_dump_command_runs_mariadb_dump_without_tls():
'--single-transaction',
'--skip-ssl',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1139,6 +1226,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_username_and_password():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1189,6 +1279,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_options():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1239,6 +1332,9 @@ def test_execute_dump_command_runs_non_default_mariadb_dump_with_options():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -719,6 +719,9 @@ def test_execute_dump_command_runs_mysqldump():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -769,6 +772,9 @@ def test_execute_dump_command_with_environment_password_transport_skips_defaults
'--user',
'root',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -817,6 +823,9 @@ def test_execute_dump_command_runs_mysqldump_without_add_drop_database():
'--defaults-extra-file=/dev/fd/99',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -874,6 +883,9 @@ def test_execute_dump_command_runs_mysqldump_with_hostname_and_port():
'--protocol',
'tcp',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -924,6 +936,9 @@ def test_execute_dump_command_runs_mysqldump_with_tls():
'--single-transaction',
'--ssl',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -974,6 +989,9 @@ def test_execute_dump_command_runs_mysqldump_without_tls():
'--single-transaction',
'--skip-ssl',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1023,6 +1041,9 @@ def test_execute_dump_command_runs_mysqldump_with_username_and_password():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1073,6 +1094,9 @@ def test_execute_dump_command_runs_mysqldump_with_options():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1122,6 +1146,9 @@ def test_execute_dump_command_runs_non_default_mysqldump():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -172,13 +172,22 @@ def test_database_names_to_dump_with_all_and_format_lists_databases_with_usernam
def test_database_names_to_dump_with_all_and_format_lists_databases_with_options():
database = {'name': 'all', 'format': 'custom', 'list_options': '--harder'}
database = {'name': 'all', 'format': 'custom', 'list_options': '--harder "foo bar"'}
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--harder'),
(
'psql',
'--list',
'--no-password',
'--no-psqlrc',
'--csv',
'--tuples-only',
'--harder',
"'foo bar'",
),
environment=object,
working_directory=None,
).and_yield('foo,test,', 'bar,test,"stuff and such"')
@@ -803,7 +812,7 @@ def test_dump_data_sources_runs_pg_dump_with_integer_compression():
def test_dump_data_sources_runs_pg_dump_with_options():
databases = [{'name': 'foo', 'options': '--stuff=such'}]
databases = [{'name': 'foo', 'options': '--stuff "foo bar"'}]
process = flexmock()
flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
@@ -826,7 +835,8 @@ def test_dump_data_sources_runs_pg_dump_with_options():
'--if-exists',
'--format',
'custom',
'--stuff=such',
'--stuff',
"'foo bar'",
'foo',
'>',
'databases/localhost/foo',
@@ -1340,8 +1350,8 @@ def test_restore_data_source_dump_runs_pg_restore_with_options():
hook_config = [
{
'name': 'foo',
'restore_options': '--harder',
'analyze_options': '--smarter',
'restore_options': '--harder "foo bar"',
'analyze_options': '--smarter "baz quux"',
'schemas': None,
},
]
@@ -1364,6 +1374,7 @@ def test_restore_data_source_dump_runs_pg_restore_with_options():
'--dbname',
'foo',
'--harder',
"'foo bar'",
),
processes=[extract_process],
output_log_level=logging.DEBUG,
@@ -1381,6 +1392,7 @@ def test_restore_data_source_dump_runs_pg_restore_with_options():
'--dbname',
'foo',
'--smarter',
"'baz quux'",
'--command',
'ANALYZE',
),
+15 -5
View File
@@ -66,7 +66,9 @@ def test_initialize_monitor_without_send_logs_does_not_add_handler():
def test_ping_monitor_respects_dry_run():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).and_return('loggy log')
@@ -83,7 +85,9 @@ def test_ping_monitor_respects_dry_run():
def test_ping_monitor_with_no_states_does_not_notify():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).never()
@@ -100,7 +104,9 @@ def test_ping_monitor_with_no_states_does_not_notify():
def test_ping_monitor_notifies_fail_by_default():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).and_return('')
@@ -123,7 +129,9 @@ def test_ping_monitor_notifies_fail_by_default():
def test_ping_monitor_with_logs_appends_logs_to_body():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).and_return('loggy log')
@@ -146,7 +154,9 @@ def test_ping_monitor_with_logs_appends_logs_to_body():
def test_ping_monitor_with_finish_default_config_notifies():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).and_return('')
+17
View File
@@ -779,6 +779,7 @@ def test_raise_for_process_errors_with_no_processes_bails():
module.raise_for_process_errors(
buffer_readers,
process_metadatas={},
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -795,6 +796,7 @@ def test_raise_for_process_errors_with_running_process_bails():
module.raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -812,6 +814,7 @@ def test_raise_for_process_errors_with_running_process_and_no_buffer_readers_wai
module.raise_for_process_errors(
buffer_readers={},
process_metadatas=process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -829,6 +832,7 @@ def test_raise_for_process_errors_with_successful_process_bails():
module.raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -846,6 +850,7 @@ def test_raise_for_process_errors_with_warning_process_returns_warning_status():
module.raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -860,6 +865,7 @@ def test_raise_for_process_errors_with_error_process_raises():
process: module.Process_metadata(last_lines=['hi', 'there'], capture=False)
}
flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.ERROR)
flexmock(module).should_receive('log_remaining_buffer_lines').and_return(())
command = flexmock()
flexmock(module).should_receive('command_for_process').and_return(command)
@@ -867,6 +873,7 @@ def test_raise_for_process_errors_with_error_process_raises():
module.raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -890,12 +897,14 @@ def test_raise_for_process_errors_with_success_process_and_warning_process_retur
flexmock(module).should_receive('interpret_exit_code').with_args(
object, 1, object, object
).and_return(module.Exit_status.WARNING)
flexmock(module).should_receive('log_remaining_buffer_lines').and_return(())
flexmock(module).should_receive('command_for_process').never()
assert (
module.raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -917,6 +926,7 @@ def test_raise_for_process_errors_with_warning_process_and_error_process_raises(
flexmock(module).should_receive('interpret_exit_code').with_args(
object, 3, object, object
).and_return(module.Exit_status.ERROR)
flexmock(module).should_receive('log_remaining_buffer_lines').and_return(())
command = flexmock()
flexmock(module).should_receive('command_for_process').and_return(command)
@@ -924,6 +934,7 @@ def test_raise_for_process_errors_with_warning_process_and_error_process_raises(
module.raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -947,6 +958,7 @@ def test_raise_for_process_errors_with_warning_process_and_running_process_kills
flexmock(module).should_receive('interpret_exit_code').with_args(
object, 1, object, object
).and_return(module.Exit_status.WARNING)
flexmock(module).should_receive('log_remaining_buffer_lines').and_return(())
command = flexmock()
flexmock(module).should_receive('command_for_process').and_return(command)
@@ -954,6 +966,7 @@ def test_raise_for_process_errors_with_warning_process_and_running_process_kills
module.raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -975,6 +988,7 @@ def test_raise_for_process_errors_with_error_process_and_running_process_kills_a
flexmock(module).should_receive('interpret_exit_code').with_args(
object, 3, object, object
).and_return(module.Exit_status.ERROR)
flexmock(module).should_receive('log_remaining_buffer_lines').and_return(())
command = flexmock()
flexmock(module).should_receive('command_for_process').and_return(command)
@@ -982,6 +996,7 @@ def test_raise_for_process_errors_with_error_process_and_running_process_kills_a
module.raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
@@ -998,6 +1013,7 @@ def test_raise_for_process_errors_with_warning_process_and_long_output_raises_wi
process: module.Process_metadata(last_lines=['hi', 'there'], capture=False)
}
flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.ERROR)
flexmock(module).should_receive('log_remaining_buffer_lines').and_return(())
command = flexmock()
flexmock(module).should_receive('command_for_process').and_return(command)
@@ -1005,6 +1021,7 @@ def test_raise_for_process_errors_with_warning_process_and_long_output_raises_wi
flexmock(module, ERROR_OUTPUT_MAX_LINE_COUNT=2).raise_for_process_errors(
buffer_readers,
process_metadatas,
output_log_level=None,
borg_local_path=flexmock(),
borg_exit_codes=flexmock(),
)
+15 -12
View File
@@ -699,7 +699,7 @@ def test_configure_logging_skips_log_file_if_log_file_logging_is_disabled():
)
def test_configure_logging_to_log_file_instead_of_syslog():
def test_configure_logging_to_log_file_instead_of_syslog(tmp_path):
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
fake_formatter = flexmock()
@@ -716,20 +716,21 @@ def test_configure_logging_to_log_file_instead_of_syslog():
)
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile')
log_file = str(tmp_path / 'logfile')
file_handler = logging.handlers.WatchedFileHandler(log_file)
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args(
'/tmp/logfile',
log_file,
).and_return(file_handler).once()
module.configure_logging(
console_log_level=logging.INFO,
syslog_log_level=logging.DISABLED,
log_file_log_level=logging.DEBUG,
log_file='/tmp/logfile',
log_file=log_file,
)
def test_configure_logging_to_both_log_file_and_syslog():
def test_configure_logging_to_both_log_file_and_syslog(tmp_path):
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
fake_formatter = flexmock()
@@ -752,20 +753,21 @@ def test_configure_logging_to_both_log_file_and_syslog():
flexmock(module.logging.handlers).should_receive('SysLogHandler').with_args(
address='/dev/log',
).and_return(syslog_handler).once()
file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile')
log_file = str(tmp_path / 'logfile')
file_handler = logging.handlers.WatchedFileHandler(log_file)
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args(
'/tmp/logfile',
log_file,
).and_return(file_handler).once()
module.configure_logging(
console_log_level=logging.INFO,
syslog_log_level=logging.DEBUG,
log_file_log_level=logging.DEBUG,
log_file='/tmp/logfile',
log_file=log_file,
)
def test_configure_logging_to_log_file_formats_with_custom_log_format():
def test_configure_logging_to_log_file_formats_with_custom_log_format(tmp_path):
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Log_prefix_formatter').with_args(
@@ -786,15 +788,16 @@ def test_configure_logging_to_log_file_formats_with_custom_log_format():
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile')
log_file = str(tmp_path / 'logfile')
file_handler = logging.handlers.WatchedFileHandler(log_file)
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args(
'/tmp/logfile',
log_file,
).and_return(file_handler).once()
module.configure_logging(
console_log_level=logging.INFO,
log_file_log_level=logging.DEBUG,
log_file='/tmp/logfile',
log_file=log_file,
log_file_format='{message}',
)