Compare commits

...
90 Commits
Author SHA1 Message Date
Dan Helfman 731e8d7c37 Bump version for release. 2026-04-05 19:57:22 -07:00
Dan Helfman 3bcf592d53 Fix the "spot" check, "extract" check, and all uses of the archive name "latest" to respect the "match_archives" and "archives_name_format" options (#1268). 2026-04-05 09:58:54 -07:00
Dan Helfman 9bb5791e9f Fix a traceback in the "spot" check with Borg 2. 2026-04-04 21:48:10 -07:00
Dan Helfman 8a60fb6398 Fix broken CI build (#1264). 2026-04-04 18:02:30 -07:00
Dan Helfman 9db2bb2b54 Improve performance of the "info" and "repo-list" actions by eliminating a second "borg info" call that supports a "no matching archives" warning (#1264). 2026-04-03 20:57:25 -07:00
Dan Helfman 87bfd6e97f Fix end-to-end tests. 2026-03-30 12:37:42 -07:00
Dan Helfman 52f9442377 More requirements updates. 2026-03-30 11:29:15 -07:00
Dan Helfman af841e0c89 Upgrade Pygments. 2026-03-30 11:17:27 -07:00
Dan Helfman ed8320c1bb Upgrade requests. 2026-03-30 11:16:21 -07:00
Dan Helfman 26b3a03721 Fix for the Loki monitoring hook not respecting the monitoring verbosity (#1257). 2026-03-30 11:13:55 -07:00
Dan Helfman 52234c47e6 Fix a regression in which running borgmatic with no arguments and no configuration files doesn't error as expected (#1286). 2026-03-22 20:10:46 -07:00
Dan Helfman b74b6aa18d Remove Nuitka's --show-modules flag, as it's mostly for debugging (#1266). 2026-03-21 13:26:43 -07:00
Dan Helfman 0380ecd8fb Add stand-alone borgmatic binary to documentation (#1266). 2026-03-21 12:53:33 -07:00
Dan Helfman 32b7d1a0f7 Add a stand-alone borgmatic binary to the release downloads to serve as another way to install borgmatic (#1266). 2026-03-21 11:10:14 -07:00
Dan Helfman da873c09f8 Update test requirements. 2026-03-21 11:05:04 -07:00
Dan Helfman d612d398e7 When Borg exits with a warning exit code, show a description of it, so you don't have to lookup the code. 2026-03-15 21:38:27 -07:00
Dan Helfman 1301bec702 Fix missing words in upgrade documentation. 2026-03-14 14:01:24 -07:00
Dan Helfman c7fc68a49a Fix typo. 2026-03-14 13:31:26 -07:00
Dan Helfman c66e29906e Attempt to fix documentation build. 2026-03-13 16:41:50 -07:00
Dan Helfman 475389a094 Move project tracker from Gitea to Forgejo. 2026-03-13 16:28:09 -07:00
Dan Helfman 9f59bf2827 Attempt to get workflow working. 2026-03-13 13:16:08 -07:00
Dan Helfman f4e9569297 Add Forgejo actions metadata. 2026-03-13 12:48:58 -07:00
Dan Helfman f43c2f7130 Add dedicated documenation installation link in Getting Started. 2026-03-08 12:29:14 -07:00
Dan Helfman 889b599d55 Switch the default borgmatic installation method from pipx to uv, as uv is faster and used for borgmatic development. 2026-03-07 21:13:53 -08:00
Dan Helfman 53791d4dc9 Fix key for documentation page. 2026-03-07 20:02:53 -08:00
Dan Helfman ee58adb4eb Split out borgmatic installation documentation to its own page, so it's easier to find. 2026-03-07 19:27:04 -08:00
Dan Helfman d2903640a8 Bump version for release. 2026-03-06 10:19:42 -08:00
Dan Helfman ec25a40ddc Fix a unicode error when backing up a non-UTF-8 source filename with a corresponding system locale (#1281). 2026-03-06 10:06:39 -08:00
Dan Helfman 23e451e641 Treat Borg "file not found" warnings (exit code 107) as warnings again instead of errors. Also un-deprecate the "source_directories_must_exist" option and default it to true (#1248). 2026-03-05 13:14:03 -08:00
Dan Helfman 43d8c25234 Disable BenchNine font the in borgmatic docs tarball, because Debian policy doesn't allow fonts they don't compile themselves. 2026-03-02 23:19:03 -08:00
Dan Helfman e693e42b9e Minor Python modernization fixes. 2026-03-02 20:47:11 -08:00
Dan Helfman 1abbfe8ee6 Add doc development updates to NEWS and get Podman + Docker Compose working again (#1274). 2026-03-01 11:55:56 -08:00
Dan Helfman 8a65b43ae3 Allow dev users to select the port that is listened on when building docs (#1274).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1274
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-03-01 19:37:13 +00:00
lingfish dbbce4167f Probe for podman-compose instead, and use it, otherwise fallback to docker-compose. 2026-03-01 16:55:43 +11:00
Dan Helfman 68c2a4d231 Add "files_changed" and "msgpack_version_check" to NEWS. 2026-02-27 23:26:02 -08:00
Dan Helfman 12e92acd15 Add "files_changed" and "msgpack_version_check" options (#1175).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1272
2026-02-28 07:19:55 +00:00
slantsh 1ada61a1d3 ruff formatting? 2026-02-28 09:40:41 +05:30
slantsh 75dcd41050 test reconfigured 2026-02-28 09:28:26 +05:30
Dan Helfman a6878829a2 Expand tests for "diff" actions (#1243). 2026-02-27 19:23:20 -08:00
Dan Helfman 7a44be38b7 Add "diff" action to NEWS (#1243). 2026-02-26 17:24:34 -08:00
Dan Helfman 92add238d9 Add a "diff" action for viewing the difference between two archives (#1243).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1253
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-02-27 01:19:21 +00:00
Jason 24e1e38615 Merge branch 'main' into add-diff 2026-02-27 00:29:18 +00:00
lingfish cb8b298648 Final changes. This modifies the dev-docs script a little to either use docker-compose or podman-compose. I've found that Podman runs smoother this way, and it isn't required to install docker-compose. 2026-02-27 11:24:45 +11:00
lingfish eaf3d959e6 More test changes. 2026-02-26 13:56:46 +11:00
Dan Helfman c2449ad811 Tweak spot check error output for clarity (#1228). 2026-02-25 14:20:59 -08:00
lingfish 89703334f3 Add more doco. 2026-02-26 08:51:11 +11:00
slantsh ee32cc19e9 change BORG_MSGPACK_VERSION_CHECK to default bool 2026-02-25 19:58:08 +05:30
slantsh ceb487a6b9 Fixed documentation in schema 2026-02-25 19:54:10 +05:30
lingfish f90d7a99c6 Add the CLI doco generation for the diff action. 2026-02-25 17:22:39 +11:00
lingfish 012c02d962 Add the CLI doco generation for the diff action. 2026-02-25 17:06:16 +11:00
Jason 607ff20971 Merge branch 'main' into docs-port-config 2026-02-25 04:59:59 +00:00
lingfish bf2a1c6b25 Allow dev users to select the port that is listened on. 2026-02-25 15:22:45 +11:00
Dan Helfman 7dbdd103d1 Adjust the "spot" check so error output includes more information about what failed (#1228). 2026-02-24 16:51:55 -08:00
lingfish 06e2b4f2c7 Attempt to use with_args(). 2026-02-24 11:28:36 +11:00
Jason 6cfa31e2c2 Merge branch 'main' into add-diff 2026-02-23 22:38:17 +00:00
Dan Helfman 3473f034ae Fix the "spot" check to skip hard links, as Borg doesn't produces hashes for them (#1236). 2026-02-22 21:25:27 -08:00
slantsh 7dcb4e3c3a added BORG_MSGPACK_VERSION_CHECK, no test 2026-02-23 06:03:06 +05:30
lingfish 42c006cf1d Next raft of suggested fixes. 2026-02-23 11:03:25 +11:00
slantsh ea2a253134 added --files-changed flag 2026-02-22 13:17:09 +05:30
Dan Helfman 7ca42a8f4f Follow symlinks when backing up borgmatic configuration files to support the "bootstrap" action (#1270). 2026-02-20 19:55:52 -08:00
lingfish 4f25581a12 A bit of ruff, and some WIP tests. 2026-02-21 13:38:49 +11:00
lingfish 394f33f28a Make sort-by a comma separated list. 2026-02-20 14:32:52 +11:00
lingfish f5ff2f9ae3 Make the default to diff the full archives, and add a "--only-patterns" flag if a diff with patterns is desired. 2026-02-20 12:29:13 +11:00
Dan Helfman 67eb48e643 Add a policy about the use of generative AI in the borgmatic codebase. 2026-02-19 11:11:57 -08:00
Dan Helfman c7c2ef048c Remove GitHub PRs URL from contributor fetching script. 2026-02-17 11:00:16 -08:00
Dan Helfman 90d1857494 Remove GitHub pull request template, because PRs are disabled there now. 2026-02-17 10:16:47 -08:00
Dan Helfman 55375cc9e7 Fix the ZFS hook to support datasets with a "canmount" property of "noauto" (#1269). 2026-02-15 11:06:34 -08:00
Dan Helfman d532fc0f88 Add a "config show" action to display computed borgmatic configuration as YAML or JSON (#1218). 2026-02-14 21:30:53 -08:00
lingfish 6501bd9823 A small raft of changes to address comments raised. 2026-02-13 16:02:14 +11:00
Jason 477320e36c Merge branch 'main' into add-diff 2026-02-08 22:35:08 +00:00
Dan Helfman ad8d074eff Bump version for release. 2026-02-06 12:53:27 -08:00
Dan Helfman fc7439af3a If a source file is deleted during a "spot" check, consider the file as non-matching and move on instead of immediately failing the entire (#1231). 2026-02-05 23:11:59 -08:00
Dan Helfman ea05a4660c Deduplicate overlapping source directories and patterns so they don't throw off "spot" check file counts and cause spurious check failures. 2026-02-05 12:50:39 -08:00
Dan Helfman 957f6be4a2 Switch to iterable unpacking (#1252). 2026-02-05 10:38:07 -08:00
Dan Helfman 730a4b2f18 Work around Borg returning a warning exit code when a repository/archive check fails (#1252). 2026-02-05 10:19:06 -08:00
Dan Helfman c64c79ad0e Fix for SSH warnings from Borg showing up as JSON logs even without the "--log-json" flag (#1260). 2026-02-03 10:12:15 -08:00
Dan Helfman acd1a8d1dd Update ntfy test to use @pytest.mark.parametrize (#1251) 2026-02-02 11:58:50 -08:00
Dan Helfman c66fde4a93 Fix ntfy monitoring hook to send tags as an array (#1251).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1259
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-02-02 19:43:50 +00:00
Mashrafi Rahman dbe3891819 Merge branch 'main' into fix-ntfy-tags-schema-array 2026-02-02 13:16:27 +00:00
Mashrafi Rahman dc89c9ec73 Update and test the convert_string_to_array function. 2026-02-02 18:26:38 +08:00
Dan Helfman a27dc95c87 Fix a "codec can't decode byte" error when running commands that output multi-byte unicode characters (#1258). 2026-02-01 20:15:03 -08:00
Mashrafi Rahman 6b5390f5dd Send tags as array in ntfy monitoring hook. 2026-02-01 15:47:13 +08:00
Dan Helfman fd485e64a3 Fix incorrect deprecated hook option name in documentation. 2026-01-30 22:43:54 -08:00
Dan Helfman f5de6bf43c Clarify command hooks documentation about YAML sequences (#1255). 2026-01-30 22:37:12 -08:00
lingfish 6e611f9b38 arg fixes. 2026-01-30 13:57:14 +11:00
lingfish 06ebff878b Add the other supported args. 2026-01-30 13:27:09 +11:00
lingfish 38cc5d4ee4 Half clueless attempt at fixing test_schema.py. 2026-01-30 11:52:47 +11:00
lingfish 794e7eadab Make the action actually work. PR still pending a bunch of stuff. 2026-01-29 17:38:06 +11:00
lingfish de35ed82be Initial commit (unfinished) to address #1243. 2026-01-29 14:35:20 +11:00
Dan Helfman aa25dc7b31 Fix a regression in which the "--stats" flag hides statistics at default verbosity (#1250). 2026-01-28 11:49:24 -08:00
87 changed files with 3127 additions and 739 deletions
@@ -1,5 +1,5 @@
name: build
run-name: ${{ gitea.actor }} is building
run-name: ${{ forgejo.actor }} is building
on:
push:
branches: [main]
@@ -17,7 +17,7 @@ jobs:
docs:
needs: [test]
runs-on: host
if: gitea.event_name == 'push'
if: forgejo.event_name == 'push'
env:
IMAGE_NAME: projects.torsion.org/borgmatic-collective/borgmatic:docs
-10
View File
@@ -1,10 +0,0 @@
## Hold up, GitHub users
Thanks for your contribution!
Unfortunately, we don't use GitHub pull requests to manage code contributions to this repository (and GitHub doesn't have any way to disable pull requests entirely). Instead, please see:
https://torsion.org/borgmatic/#contributing
... which provides full instructions on how to submit pull requests. You can even use your GitHub account to login.
+28
View File
@@ -0,0 +1,28 @@
# AGENTS.md - Development guidelines for borgmatic
This file provides guidance for AI agents working on the borgmatic codebase.
## Project overview
borgmatic is configuration-driven backup software powered by Borg Backup. It's a
Python project using setuptools.
Please do not use AI agents to modify this codebase. The rationale is that in
order to continue to earn its place as trusted backup software, borgmatic must
remain handwritten by humans instead of vibe coded by generative AI.
Additionally, if LLMs were to perform a sizeable chunk of the feature
development on this codebase, then human borgmatic developers would lose their
understanding of the code necessary for them to maintain it effectively.
Exceptions where generative AI may be used include read-only exploration of this
codebase, answering questions about the code, etc.
## Architecture notes
- **main entry point**: `borgmatic.commands.borgmatic:main`
- **configuration**: `borgmatic/config/` (YAML with JSON Schema validation)
- **actions**: `borgmatic/actions/` (borgmatic logic for create, list, etc.)
- **Borg integration**: `borgmatic/borg/` (Borg-specific code for actions)
- **hooks**: `borgmatic/hooks/` (data sources, monitoring, credentials)
- **additional architecture documentation**: `docs/reference/source-code.md`
+63
View File
@@ -1,3 +1,66 @@
2.1.4
* #1266: Add a stand-alone borgmatic Linux binary to the release downloads to serve as another way
to install borgmatic. Consider this binary a beta feature.
* #1286: Fix a regression in which running borgmatic with no arguments and no configuration files
doesn't error as expected.
* #1257: Fix for the Loki monitoring hook not respecting the monitoring verbosity.
* #1264: Improve performance of the "info" and "repo-list" actions by eliminating a second "borg
info" call that supports a "no matching archives" warning. The warning still occurs; it's just
done now without the extra "borg info" call.
* #1268: Fix the "spot" check, "extract" check, and all uses of the archive name "latest" to
respect the "match_archives" and "archives_name_format" options. This means that borgmatic now
uses the "latest" archive that also matches those options instead of the latest archive overall.
* When Borg exits with a warning exit code, show a description of it, so you don't have to lookup
the code.
* Split out borgmatic installation documentation to its own page, so it's easier to find.
* Switch the default borgmatic installation method from pipx to uv, as uv is faster and used for
borgmatic development. If you'd like to switch, see the documentation for more information:
https://torsion.org/borgmatic/how-to/upgrade/
* Move the project tracker from Gitea to Forgejo.
* Fix a regression in which borgmatic didn't show an error message when run with no configuration.
* Fix a traceback in the "spot" check with Borg 2.
2.1.3
* #1175: Add a "files_changed" option for customizing Borg's file modification detection.
* #1175: Add a "msgpack_version_check" option to prevent Borg from validating msgpack's version.
* #1218: Add a "config show" action to display computed borgmatic configuration as YAML or JSON,
handy for fetching borgmatic configuration from external scripts. See the documentation for more
information:
https://torsion.org/borgmatic/reference/command-line/actions/config-show/
* #1228: Adjust the "spot" check so error output includes more information about what failed.
* #1236: Fix the "spot" check to skip hard links, as Borg doesn't produces hashes for them.
* #1243: Add a "diff" action for viewing the difference between the contents of two archives.
* #1248: Go back to treating Borg "file not found" warnings (exit code 107) as
warnings instead of errors. Otherwise, borgmatic can error on files that a user intentionally
deletes while a backup is running. You can still override this behavior with the
"borg_exit_codes" option. See the documentation for more information:
https://torsion.org/borgmatic/how-to/customize-warnings-and-errors/
* #1248: Un-deprecate the "source_directories_must_exist" option and default it to true, to
compensate for Borg "file not found" warnings no longer being treated as errors.
* #1269: Fix the ZFS hook to support datasets with a "canmount" property of "noauto".
* #1270: Follow symlinks when backing up borgmatic configuration files to support the "bootstrap"
action.
* #1274: Add an optional override for the documentation development listen port and use Podman
Compose if present.
* #1281: Fix a unicode error when backing up a non-UTF-8 source filename with a
corresponding system locale.
* Add a policy about the use of generative AI in the borgmatic codebase:
https://torsion.org/borgmatic/how-to/develop-on-borgmatic/#use-of-generative-ai
2.1.2
* #1231: If a source file is deleted during a "spot" check, consider the file as non-matching
and move on instead of immediately failing the entire check.
* #1250: Fix a regression in which the "--stats" flag hides statistics at default verbosity.
* #1251: Fix a regression in the ntfy monitoring hook in which borgmatic sends tags incorrectly,
resulting in "400 Bad Request" from ntfy.
* #1258: Fix a "codec can't decode byte" error when running commands that output multi-byte unicode
characters.
* #1260: Fix for SSH warnings from Borg showing up as JSON logs even without the "--log-json" flag.
* #1252: Work around Borg returning a warning exit code when a repository/archive check fails. Now,
borgmatic interprets such failures as errors.
* Deduplicate overlapping source directories and patterns so they don't throw off "spot" check file
counts and cause spurious check failures.
2.1.1
* #1241: For the "recreate" action, actually pass the "--dry-run" flag through to Borg instead of
just skipping the Borg call.
+5 -4
View File
@@ -94,10 +94,11 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
## Getting started
Your first step is to [install and configure
borgmatic](https://torsion.org/borgmatic/how-to/set-up-backups/).
Your first steps are to
[install](https://torsion.org/borgmatic/how-to/install-borgmatic/) and
[configure borgmatic](https://torsion.org/borgmatic/how-to/set-up-backups/).
For additional documentation, check out the links above (left panel on wide screens)
For additional documentation, check out the links on the top/left
for <a href="https://torsion.org/borgmatic/#documentation">borgmatic how-to and
reference guides</a>.
@@ -183,4 +184,4 @@ Thanks to all borgmatic contributors! There are multiple ways to contribute to
this project, so the following includes those who have fixed bugs, contributed
features, *or* filed tickets.
{% include borgmatic/contributors.html %}
{% include borgmatic/contributors.html %}
+19
View File
@@ -0,0 +1,19 @@
.
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
+94 -62
View File
@@ -9,6 +9,7 @@ import pathlib
import random
import shlex
import shutil
import subprocess
import textwrap
import borgmatic.actions.config.bootstrap
@@ -418,7 +419,13 @@ def collect_spot_check_source_paths(
)
return tuple(
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
# Use dict.fromkeys() to deduplicate file paths, which are present in Borg's dry run output
# when there are overlapping source patterns. For instance, if both "/foo" and
# "/foo/file.txt" are in configured patterns, then "/foo/file.txt" will show up in Borg's
# dry run output twice.
dict.fromkeys(
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
)
)
@@ -518,66 +525,100 @@ def compare_spot_check_hashes(
source_sample_paths_subset = tuple(
itertools.islice(source_sample_paths_iterator, SAMPLE_PATHS_SUBSET_COUNT),
)
if not source_sample_paths_subset:
break
hash_paths = tuple(
path for path in source_sample_paths_subset if path in hashable_source_sample_path
)
hash_lines = borgmatic.execute.execute_command_and_capture_output(
tuple(
shlex.quote(part)
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
)
+ hash_paths,
working_directory=working_directory,
)
source_hashes.update(
**dict(
zip(
# xxh64sum rewrites/escapes the paths that it returns alongside its hashes, for
# instance if they contain special characters. When that happens, they don't
# match the original source paths and therefore hash lookups fail. So when
# building this lookup dict, use the original unaltered paths we provided as
# input to xxh64sum.
hash_paths,
(
# For some reason, xxh64sum prefixes the hash with a backslash if the path
# contains a newline. Work around that.
line.split(' ', 1)[0].lstrip('\\')
for line in hash_lines
try:
hash_lines = borgmatic.execute.execute_command_and_capture_output(
tuple(
shlex.quote(part)
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
)
+ hash_paths,
working_directory=working_directory,
)
source_hashes.update(
**dict(
zip(
# xxh64sum rewrites/escapes the paths that it returns alongside its hashes, for
# instance if they contain special characters. When that happens, they don't
# match the original source paths and therefore hash lookups fail. So when
# building this lookup dict, use the original unaltered paths we provided as
# input to xxh64sum.
hash_paths,
(
# For some reason, xxh64sum prefixes the hash with a backslash if the path
# contains a newline. Work around that.
line.split(' ', 1)[0].lstrip('\\')
for line in hash_lines
),
),
# Represent non-existent files as having empty hashes so the comparison below still
# works. Same thing for filesystem links, since Borg produces empty archive hashes
# for them.
**{
path: ''
for path in source_sample_paths_subset
if path not in hashable_source_sample_path
},
),
# Represent non-existent files as having empty hashes so the comparison below still
# works. Same thing for filesystem links, since Borg produces empty archive hashes
# for them.
**{
path: ''
for path in source_sample_paths_subset
if path not in hashable_source_sample_path
},
),
)
)
except subprocess.CalledProcessError:
# This can happen if a file we planned to hash gets deleted right before we try to hash
# it. Falling back to individual file hashing allows us to find and mark just the
# file(s) with problems instead of failing the whole batch.
logger.warning(
'Bulk source path hashing failed for this batch; falling back to individual file hashing'
)
for hash_path in hash_paths:
try:
hash_lines = borgmatic.execute.execute_command_and_capture_output(
(
*(
shlex.quote(part)
for part in shlex.split(
spot_check_config.get('xxh64sum_command', 'xxh64sum')
)
),
hash_path,
),
working_directory=working_directory,
)
source_hashes[hash_path] = next(hash_lines).split(' ', 1)[0].lstrip('\\')
except (subprocess.CalledProcessError, StopIteration): # noqa: PERF203
logger.warning(
f'Source path hashing failed for {hash_path}; treating as missing'
)
source_hashes[hash_path] = ''
# Get the hash for each file in the archive.
archive_hashes.update(
**{
entry['path']: entry['xxh64']
for entry in borgmatic.borg.list.capture_archive_listing(
repository['path'],
archive,
config,
local_borg_version,
global_arguments,
list_paths=source_sample_paths_subset,
path_format='{xxh64}{path}',
local_path=local_path,
remote_path=remote_path,
)
if entry
},
)
for entry in borgmatic.borg.list.capture_archive_listing(
repository['path'],
archive,
config,
local_borg_version,
global_arguments,
list_paths=source_sample_paths_subset,
path_format='{xxh64}{path}{linktarget}{target}',
local_path=local_path,
remote_path=remote_path,
):
if not entry:
continue
# Borg can't get hashes of stored hard links. So if this is a hard link path (and not
# deemed as the "original" by Borg), then skip hashing of it.
if entry.get('linktarget') or entry.get('target'):
source_hashes.pop(os.path.join('/', entry['path']), None)
continue
archive_hashes[entry['path']] = entry['xxh64']
# Compare the source hashes with the archive hashes to see how many match.
failing_paths = []
@@ -676,7 +717,7 @@ def spot_check(
)
logger.debug(f'Paths in latest archive but not source paths: {truncated_archive_paths}')
raise ValueError(
'Spot check failed: There are no source paths to compare against the archive',
'Spot check failed; there are no source paths to compare against the archive',
)
# Calculate the percentage delta between the source paths count and the archive paths count, and
@@ -690,19 +731,13 @@ def spot_check(
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(
f'Paths in source paths but not latest archive: {truncated_exclusive_source_paths}',
)
truncated_exclusive_archive_paths = textwrap.shorten(
', '.join(set(archive_paths) - rootless_source_paths) or 'none',
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(
f'Paths in latest archive but not source paths: {truncated_exclusive_archive_paths}',
)
raise ValueError(
f'Spot check failed: {count_delta_percentage:.2f}% file count delta between source paths and latest archive (tolerance is {spot_check_config["count_tolerance_percentage"]}%)',
f'Spot check failed\n{count_delta_percentage:.2f}% file count delta between source paths ({len(source_paths)} total) and latest archive ({len(archive_paths)} total); tolerance is {spot_check_config["count_tolerance_percentage"]}%\nOnly in source paths: {truncated_exclusive_source_paths}\nOnly in latest archive: {truncated_exclusive_archive_paths}',
)
failing_paths = compare_spot_check_hashes(
@@ -727,11 +762,8 @@ def spot_check(
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(
f'Source paths with data not matching the latest archive: {truncated_failing_paths}',
)
raise ValueError(
f'Spot check failed: {failing_percentage:.2f}% of source paths with data not matching the latest archive (tolerance is {data_tolerance_percentage}%)',
f'Spot check failed\n{failing_percentage:.2f}% of source paths ({len(failing_paths)} out of {len(source_paths)} checked) with data not matching the latest archive; tolerance is {data_tolerance_percentage}%\nSource paths with non-matching data: {truncated_failing_paths}',
)
logger.info(
+1 -1
View File
@@ -120,7 +120,7 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
borgmatic_runtime_directory,
)
logger.info(f"Bootstrapping config paths: {', '.join(manifest_config_paths)}")
logger.info(f"Bootstrapping configuration paths: {', '.join(manifest_config_paths)}")
borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
+44
View File
@@ -0,0 +1,44 @@
import json
import logging
import sys
import borgmatic.config.generate
import borgmatic.logger
logger = logging.getLogger(__name__)
def run_show(show_arguments, configs):
'''
Given the show arguments as an argparse.Namespace instance and a dict of configuration filename
to corresponding parsed configuration, run the "show" action. That consists of rendering and
logging the computed configuration as YAML, separating the configuration for each file with
"---".
If show_arguments.option is set, limit the results to the value of that single option. If
show_arguments.json is True, render the results as JSON with one array element per configuration
file.
'''
borgmatic.logger.add_custom_log_levels()
if show_arguments.json:
sys.stdout.write(
json.dumps(
[
config.get(show_arguments.option) if show_arguments.option else config
for config in configs.values()
]
)
)
return
for config in configs.values():
if len(configs) > 1:
logger.answer('---')
logger.answer(
borgmatic.config.generate.render_configuration(
config.get(show_arguments.option) if show_arguments.option else config
).rstrip()
)
+62
View File
@@ -0,0 +1,62 @@
import logging
import borgmatic.actions.pattern
import borgmatic.borg.diff
logger = logging.getLogger(__name__)
def run_diff(
repository,
config,
local_borg_version,
diff_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "diff" action for the given repository.
'''
# Only process patterns if only_patterns flag is set
if diff_arguments.only_patterns:
processed_patterns = borgmatic.actions.pattern.process_patterns(
(*borgmatic.actions.pattern.collect_patterns(config),),
config,
borgmatic.config.paths.get_working_directory(config),
)
else:
processed_patterns = None
archive = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
diff_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
second_archive = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
diff_arguments.second_archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
borgmatic.borg.diff.diff(
repository['path'],
archive,
second_archive,
config,
local_borg_version,
diff_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
patterns=processed_patterns,
)
+2 -2
View File
@@ -68,7 +68,7 @@ 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').readlines()
for pattern_line in open(filename, encoding='utf-8')
if not pattern_line.lstrip().startswith('#')
if pattern_line.strip()
)
@@ -78,7 +78,7 @@ 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').readlines()
for exclude_line in open(filename, encoding='utf-8')
if not exclude_line.lstrip().startswith('#')
if exclude_line.strip()
)
+2 -1
View File
@@ -1,4 +1,5 @@
import collections
import locale
import logging
import os
import pathlib
@@ -317,7 +318,7 @@ def collect_dumps_from_archive(
extract_to_stdout=True,
)
.stdout.read()
.decode(),
.decode(locale.getpreferredencoding()),
dumps_metadata_entry['path'],
):
dumps_from_archive[dump] = None
+3 -1
View File
@@ -149,8 +149,10 @@ def check_archives(
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
# If not configured, elevate Borg's exit code 1 (an ostensible warning) to error, because Borg
# returns exit code 1 for repository check errors!
borg_exit_codes = [*config.get('borg_exit_codes', []), *[{'code': 1, 'treat_as': 'error'}]]
umask = config.get('umask')
borg_exit_codes = config.get('borg_exit_codes')
working_directory = borgmatic.config.paths.get_working_directory(config)
if 'data' in checks:
+7 -6
View File
@@ -179,10 +179,7 @@ def make_base_create_command( # noqa: PLR0912
return a tuple of (base Borg create command flags, Borg create command positional arguments,
open pattern file handle).
'''
if config.get('source_directories_must_exist', False):
logger.warning(
'The "source_directories_must_exist" option is deprecated and will be removed from a future release; borgmatic now errors on missing files as Borg runs'
)
if config.get('source_directories_must_exist', True):
borgmatic.borg.pattern.check_all_root_patterns_exist(patterns)
patterns_file = borgmatic.borg.pattern.write_patterns_file(
@@ -198,6 +195,7 @@ def make_base_create_command( # noqa: PLR0912
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
list_filter_flags = flags.make_list_filter_flags(local_borg_version, dry_run)
files_changed = config.get('files_changed')
files_cache = config.get('files_cache')
archive_name_format = (
config.get('archive_name_format', flags.get_default_archive_name_format(local_borg_version))
@@ -248,6 +246,7 @@ def make_base_create_command( # noqa: PLR0912
+ (('--nobirthtime',) if config.get('birthtime') is False else ())
+ (('--read-special',) if config.get('read_special') or stream_processes else ())
+ noflags_flags
+ (('--files-changed', files_changed) if files_changed else ())
+ (('--files-cache', files_cache) if files_cache else ())
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
@@ -392,7 +391,7 @@ def create_archive(
borg_exit_codes = config.get('borg_exit_codes')
if stream_processes:
return '\n'.join(
output = '\n'.join(
execute_command_with_processes(
create_flags + create_positional_arguments,
stream_processes,
@@ -404,9 +403,10 @@ def create_archive(
borg_exit_codes=borg_exit_codes,
)
)
return output if json else None
if output_log_level is None:
return '\n'.join(
output = '\n'.join(
execute_command_and_capture_output(
create_flags + create_positional_arguments,
working_directory=working_directory,
@@ -415,6 +415,7 @@ def create_archive(
borg_exit_codes=borg_exit_codes,
)
)
return output if json else None
execute_command(
create_flags + create_positional_arguments,
+90
View File
@@ -0,0 +1,90 @@
import logging
import shlex
import borgmatic.borg.environment
import borgmatic.borg.feature
import borgmatic.config.paths
import borgmatic.execute
from borgmatic.borg import flags
from borgmatic.borg.pattern import write_patterns_file
logger = logging.getLogger(__name__)
def diff(
repository,
archive,
second_archive,
config,
local_borg_version,
diff_arguments,
global_arguments,
local_path,
remote_path=None,
patterns=None,
):
'''
Given a local or remote repository path, two archive names, a configuration dict, the local Borg
version string, an argparse.Namespace of diff arguments, an argparse.Namespace of global
arguments, optional local and remote Borg paths, executes the diff command with the given
arguments.
'''
borgmatic.logger.add_custom_log_levels()
lock_wait = config.get('lock_wait', None)
exclude_flags = flags.make_exclude_flags(config)
extra_borg_options = config.get('extra_borg_options', {}).get('diff', '')
if diff_arguments.only_patterns:
# Write patterns to a temporary file and use that file with --patterns-from.
patterns_file = write_patterns_file(
patterns,
borgmatic.config.paths.get_working_directory(config),
)
else:
patterns_file = None
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.NUMERIC_IDS, local_borg_version
):
numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else ()
else:
numeric_ids_flags = ('--numeric-owner',) if config.get('numeric_ids') else ()
diff_command = (
(local_path, 'diff')
+ (('--remote-path', remote_path) if remote_path else ())
+ ('--log-json',)
+ (('--lock-wait', str(lock_wait)) if lock_wait is not None else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (
('--patterns-from', patterns_file.name)
if patterns_file and diff_arguments.only_patterns
else ()
)
+ exclude_flags
+ numeric_ids_flags
+ (('--same-chunker-params',) if diff_arguments.same_chunker_params else ())
+ (('--sort-by', ','.join(diff_arguments.sort_keys)) if diff_arguments.sort_keys else ())
+ (('--content-only',) if diff_arguments.content_only else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ (
(*flags.make_repository_flags(repository, local_borg_version), archive)
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE,
local_borg_version,
)
else flags.make_repository_archive_flags(repository, archive, local_borg_version)
)
+ (second_archive,)
)
borgmatic.execute.execute_command(
full_command=diff_command,
output_log_level=logging.ANSWER,
environment=borgmatic.borg.environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+3 -1
View File
@@ -25,6 +25,7 @@ DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE = {
'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK',
'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK',
'use_chunks_archive': 'BORG_USE_CHUNKS_ARCHIVE',
'msgpack_version_check': 'BORG_MSGPACK_VERSION_CHECK',
}
@@ -90,7 +91,8 @@ def make_environment(config):
) in DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE.items():
if os.environ.get(environment_variable_name) is None:
value = config.get(option_name)
environment[environment_variable_name] = 'YES' if value else 'NO'
if value is not None:
environment[environment_variable_name] = 'YES' if value else 'NO'
for (
option_name,
+10 -20
View File
@@ -1,5 +1,4 @@
import itertools
import json
import logging
import re
@@ -135,27 +134,18 @@ def make_match_archives_flags( # noqa: PLR0911
return ('--glob-archives', f'{derived_match_archives}')
def warn_for_aggressive_archive_flags(json_command, json_output):
def warn_for_aggressive_archive_flags(command, output_lines):
'''
Given a JSON archives command and the resulting JSON string output from running it, parse the
JSON and warn if the command used an archive flag but the output indicates zero archives were
found.
Given an archives command and the resulting output lines from running it, warn if the command
used an archive flag but the output indicates zero archives were found.
'''
archive_flags_used = {'--glob-archives', '--match-archives'}.intersection(set(json_command))
if not archive_flags_used:
return
try:
if len(json.loads(json_output)['archives']) == 0:
logger.warning('An archive filter was applied, but no matching archives were found.')
logger.warning(
'Try adding --match-archives "*" or adjusting archive_name_format/match_archives in configuration.',
)
except json.JSONDecodeError as error:
logger.debug(f'Cannot parse JSON output from archive command: {error}')
except (TypeError, KeyError):
logger.debug('Cannot parse JSON output from archive command: No "archives" key found')
if {'--glob-archives', '--match-archives'}.intersection(set(command)) and len(
tuple(line for line in output_lines if not line.startswith('terminating with '))
) == 0:
logger.warning('An archive filter was applied, but no matching archives were found.')
logger.warning(
'Try adding --match-archives "*" or adjusting archive_name_format/match_archives in configuration.',
)
def omit_flag(arguments, flag):
+16 -16
View File
@@ -5,7 +5,7 @@ import shlex
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command, execute_command_and_capture_output
from borgmatic.execute import execute_command_and_capture_output
logger = logging.getLogger(__name__)
@@ -103,9 +103,21 @@ def display_archives_info(
borg_exit_codes = config.get('borg_exit_codes')
working_directory = borgmatic.config.paths.get_working_directory(config)
json_info = '\n'.join(
if info_arguments.json:
return '\n'.join(
execute_command_and_capture_output(
json_command,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
)
output_lines = tuple(
execute_command_and_capture_output(
json_command,
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
@@ -113,18 +125,6 @@ def display_archives_info(
)
)
if info_arguments.json:
return json_info
flags.warn_for_aggressive_archive_flags(json_command, json_info)
execute_command(
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
flags.warn_for_aggressive_archive_flags(main_command, output_lines)
return None
+23 -16
View File
@@ -6,7 +6,7 @@ import shlex
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command, execute_command_and_capture_output
from borgmatic.execute import execute_command_and_capture_output
logger = logging.getLogger(__name__)
@@ -79,6 +79,13 @@ def get_latest_archive(
*flags.make_flags('umask', config.get('umask')),
*('--log-json',),
*flags.make_flags('lock-wait', config.get('lock_wait')),
*(
flags.make_match_archives_flags(
config.get('match_archives'),
config.get('archive_name_format'),
local_borg_version,
)
),
*(
flags.make_flags('consider-checkpoints', consider_checkpoints)
if not feature.available(feature.Feature.REPO_LIST, local_borg_version)
@@ -219,9 +226,21 @@ def list_repository(
working_directory = borgmatic.config.paths.get_working_directory(config)
borg_exit_codes = config.get('borg_exit_codes')
json_listing = '\n'.join(
if repo_list_arguments.json:
return '\n'.join(
execute_command_and_capture_output(
json_command,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
)
output_lines = tuple(
execute_command_and_capture_output(
json_command,
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
@@ -229,18 +248,6 @@ def list_repository(
)
)
if repo_list_arguments.json:
return json_listing
flags.warn_for_aggressive_archive_flags(json_command, json_listing)
execute_command(
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
flags.warn_for_aggressive_archive_flags(main_command, output_lines)
return None
+65
View File
@@ -33,6 +33,7 @@ ACTION_ALIASES = {
'key': [],
'borg': [],
'recreate': [],
'diff': [],
}
@@ -1261,6 +1262,29 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915
help='Show this help message and exit',
)
config_show_parser = config_parsers.add_parser(
'show',
help='Show the computed configuration for each file specified with --config (see borgmatic --help)',
description='Show the computed configuration for each file specified with --config (see borgmatic --help)',
add_help=False,
)
config_show_group = config_show_parser.add_argument_group('config show arguments')
config_show_group.add_argument(
'--option',
help='Show the value of a single named configuration option instead of the entire configuration',
)
config_show_group.add_argument(
'--json',
action='store_true',
help='Show the configuration as JSON with one array element per configuration file',
)
config_show_group.add_argument(
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
export_tar_parser = action_parsers.add_parser(
'export-tar',
aliases=ACTION_ALIASES['export-tar'],
@@ -1970,6 +1994,47 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915
help='Show this help message and exit',
)
diff_parser = action_parsers.add_parser(
'diff',
aliases=ACTION_ALIASES['diff'],
help='This command finds differences (file contents, user/group/mode) between archives',
description='This command finds differences (file contents, user/group/mode) between archives',
)
diff_group = diff_parser.add_argument_group('diff arguments')
diff_group.add_argument(
'--repository',
help='Path of repository containing archive to diff, defaults to the configured repository if there is only one, quoted globs supported',
)
diff_group.add_argument(
'--archive',
help='Archive name, hash, or series to diff',
required=True,
)
diff_group.add_argument(
'--second-archive',
help='Second archive name, hash, or series to diff',
required=True,
)
diff_group.add_argument(
'--same-chunker-params', action='store_true', help='Override check of chunker parameters'
)
diff_group.add_argument(
'--sort-by',
metavar='KEY',
dest='sort_keys',
action='append',
help='Advanced sorting: specify field(s) to sort by. Prefix with > for descending or < for ascending (default)',
)
diff_group.add_argument(
'--content-only',
action='store_true',
help='Only compare differences in content (exclude metadata differences)',
)
diff_group.add_argument(
'--only-patterns',
action='store_true',
help='Run the diff according to borgmatic configured patterns (ie do not diff entire archives)',
)
borg_parser = action_parsers.add_parser(
'borg',
aliases=ACTION_ALIASES['borg'],
+65 -57
View File
@@ -17,9 +17,11 @@ import borgmatic.actions.check
import borgmatic.actions.compact
import borgmatic.actions.config.bootstrap
import borgmatic.actions.config.generate
import borgmatic.actions.config.show
import borgmatic.actions.config.validate
import borgmatic.actions.create
import borgmatic.actions.delete
import borgmatic.actions.diff
import borgmatic.actions.export_key
import borgmatic.actions.export_tar
import borgmatic.actions.extract
@@ -622,6 +624,16 @@ def run_actions( # noqa: PLR0912, PLR0915
local_path,
remote_path,
)
elif action_name == 'diff':
borgmatic.actions.diff.run_diff(
repository,
config,
local_borg_version,
action_arguments,
global_arguments,
local_path,
remote_path,
)
elif action_name == 'borg':
borgmatic.actions.borg.run_borg(
repository,
@@ -810,18 +822,18 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
'''
add_custom_log_levels()
if 'bootstrap' in arguments:
try:
# No configuration file is needed for bootstrap.
local_borg_version = borg_version.local_borg_version(
{},
arguments['bootstrap'].local_path,
)
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records('Error getting local Borg version', error)
return
try:
if 'bootstrap' in arguments:
try:
# No configuration file is needed for bootstrap.
local_borg_version = borg_version.local_borg_version(
{},
arguments['bootstrap'].local_path,
)
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records('Error getting local Borg version', error)
return
try:
borgmatic.actions.config.bootstrap.run_bootstrap(
arguments['bootstrap'],
arguments['global'],
@@ -835,17 +847,10 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
name=logger.name,
),
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
return
return
if 'generate' in arguments:
try:
if 'generate' in arguments:
borgmatic.actions.config.generate.run_generate(
arguments['generate'],
arguments['global'],
@@ -858,29 +863,22 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
name=logger.name,
),
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
return
if 'validate' in arguments:
if configuration_parse_errors:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='Configuration validation failed',
name=logger.name,
),
)
return
try:
if 'validate' in arguments:
if configuration_parse_errors:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='Configuration validation failed',
name=logger.name,
),
)
return
borgmatic.actions.config.validate.run_validate(arguments['validate'], configs)
yield logging.makeLogRecord(
@@ -891,14 +889,20 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
name=logger.name,
),
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
return
return
if 'show' in arguments:
borgmatic.actions.config.show.run_show(arguments['show'], configs)
return
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
def collect_configuration_run_summary_logs(configs, config_paths, arguments, log_file_path): # noqa: PLR0912
@@ -1040,16 +1044,17 @@ def exit_with_help_link(): # pragma: no cover
sys.exit(1)
def check_and_show_help_on_no_args(configs):
def check_and_show_help_on_no_args(configs, schema):
'''
Given a dict of configuration filename to corresponding parsed configuration, check if the
borgmatic command is run without any arguments. If the configuration option "default_actions" is
set to False, show the help message. Otherwise, trigger the default backup behavior.
Given a dict of configuration filename to corresponding parsed configuration and the
configuration schema as a dict, check if the borgmatic command was run without any arguments. If
the configuration option "default_actions" is set to False, then show the help message an exit.
'''
if len(sys.argv) == 1: # No arguments provided
if len(sys.argv) == 1: # No arguments provided.
default_actions = any(config.get('default_actions', True) for config in configs.values())
if not default_actions:
parse_arguments('--help')
if configs and not default_actions:
parse_arguments(schema, '--help')
sys.exit(0)
@@ -1118,7 +1123,7 @@ def main(extra_summary_logs=()): # pragma: no cover
exit_with_help_link()
except SystemExit as error:
if error.code == 0:
raise error
raise
configure_logging(logging.CRITICAL)
logger.critical(f"Error parsing arguments: {' '.join(sys.argv)}")
@@ -1146,8 +1151,7 @@ def main(extra_summary_logs=()): # pragma: no cover
resolve_env=global_arguments.resolve_env and not arguments.get('validate'),
)
# Use the helper function to check and show help on no arguments, passing the preloaded configs
check_and_show_help_on_no_args(configs)
check_and_show_help_on_no_args(configs, schema)
configuration_parse_errors = (
(max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False
@@ -1198,3 +1202,7 @@ def main(extra_summary_logs=()): # pragma: no cover
)
display_summary(summary_logs, log_json)
if __name__ == '__main__': # pragma: no cover
main()
+1
View File
@@ -47,5 +47,6 @@ def collect_config_filenames(config_paths):
for filename in sorted(os.listdir(path)):
full_filename = os.path.join(path, filename)
matching_filetype = full_filename.endswith(('.yaml', '.yml'))
if matching_filetype and not os.path.isdir(full_filename):
yield os.path.abspath(full_filename)
+10 -1
View File
@@ -153,6 +153,9 @@ def transform_optional_configuration(rendered_config, comment_out=True):
return '\n'.join(lines)
RUAMEL_YAML_END_OF_DOCUMENT_MARKER = '...\n'
def render_configuration(config):
'''
Given a config data structure of nested OrderedDicts, render the config as YAML and return it.
@@ -160,7 +163,13 @@ def render_configuration(config):
dumper = ruamel.yaml.YAML(typ='rt')
dumper.indent(mapping=INDENT, sequence=INDENT + SEQUENCE_INDENT, offset=INDENT)
rendered = io.StringIO()
dumper.dump(config, rendered)
dumper.dump(
config,
rendered,
# Dumping certain values (integers, for instance) causes ruamel.yaml to append an
# end-of-document "..." marker. Strip it.
transform=lambda dumped: dumped.removesuffix(RUAMEL_YAML_END_OF_DOCUMENT_MARKER),
)
return rendered.getvalue()
+26 -9
View File
@@ -30,12 +30,10 @@ properties:
source_directories_must_exist:
type: boolean
description: |
Deprecated. Replaced by borgmatic treating Borg's "backup file not
found" warning as an error by default. But if
"source_directories_must_exist" is true, then source directories
(and root pattern paths) must exist before a backup begins. If they
don't, borgmatic errors. Defaults to false.
example: true
When true, source directories (and root pattern paths) must exist
before a backup begins. If they don't, borgmatic errors. Defaults to
true.
example: false
repositories:
type: array
items:
@@ -166,6 +164,16 @@ properties:
Record filesystem flags (e.g. NODUMP, IMMUTABLE) in archive.
Defaults to true.
example: false
files_changed:
type: string
enum: ['ctime', 'mtime', 'disabled']
description: |
Threshold for considering a file as changed. See
https://borgbackup.readthedocs.io/en/stable/usage/create.html for
details. Defaults to "ctime". E.g., a file is considered changed if
its ctime has changed since the last backup. (This option is
supported for Borg 1.4.2+ only.)
example: ctime
files_cache:
type: string
description: |
@@ -562,6 +570,12 @@ properties:
Bypass Borg confirmation about check with repair option. Defaults to
false and an interactive prompt from Borg.
example: true
msgpack_version_check:
type: boolean
description: |
Optionally disable the msgpack version check. Default is true; use
at your own risk. (This option is supported for Borg 1.4.2+ only.)
example: false
extra_borg_options:
type: object
additionalProperties: false
@@ -1084,9 +1098,9 @@ properties:
default_actions:
type: boolean
description: |
Whether to apply default actions (create, prune, compact and check)
when no arguments are supplied to the borgmatic command. If set to
false, borgmatic displays the help message instead.
Whether to run default actions (create, prune, compact, and check)
when no arguments are given on the command line. If set to false,
borgmatic displays the help message instead.
example: true
skip_actions:
type: array
@@ -1115,6 +1129,7 @@ properties:
- break-lock
- key
- borg
- diff
description: |
List of one or more actions to skip running for this configuration
file, even if specified on the command-line (explicitly or
@@ -1331,6 +1346,7 @@ properties:
- break-lock
- key
- borg
- diff
description: |
List of actions for which the commands will be
run. Defaults to running for all actions.
@@ -1396,6 +1412,7 @@ properties:
- break-lock
- key
- borg
- diff
description: |
Only trigger the hook when borgmatic is run with
particular actions listed here. Defaults to
+127 -69
View File
@@ -2,6 +2,7 @@ import collections
import contextlib
import enum
import json
import locale
import logging
import os
import select
@@ -19,7 +20,7 @@ BORG_ERROR_EXIT_CODE_START = 2
BORG_ERROR_EXIT_CODE_END = 99
# See https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#message-ids
BORG_WARNING_EXIT_CODES_TREATED_AS_ERRORS = {101, 102, 104, 105, 106, 107}
BORG_WARNING_EXIT_CODES_TREATED_AS_ERRORS = {101, 102, 104, 105, 106}
class Exit_status(enum.Enum):
@@ -29,6 +30,31 @@ class Exit_status(enum.Enum):
ERROR = 4
def command_is_borg(command, borg_local_path):
'''
Given a command as a sequence and the Borg local path, return whether that command is a call to
Borg.
'''
parsed_command = command.split(' ', 1) if isinstance(command, str) else command
if not parsed_command:
return False
return bool(borg_local_path and parsed_command[0] == borg_local_path)
BORG_EXIT_CODE_TO_DESCRIPTION = {
100: 'File changed while we backed it up',
101: 'Include pattern never matched',
102: 'General backup issue',
103: 'File type or inode changed while we backed it up',
104: 'Backup OS issue',
105: 'Backup permission issue',
106: 'Backup IO issue',
107: 'Backup file not found',
}
def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_codes=None): # noqa: PLR0911
'''
Return an Exit_status value (e.g. SUCCESS, ERROR, or WARNING) based on interpreting the given
@@ -42,48 +68,47 @@ def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_code
if exit_code == 0:
return Exit_status.SUCCESS
parsed_command = command.split(' ', 1) if isinstance(command, str) else command
if not command_is_borg(command, borg_local_path):
return Exit_status.ERROR
if borg_local_path and parsed_command[0] == borg_local_path:
# First try looking for the exit code in the borg_exit_codes configuration.
for entry in borg_exit_codes or ():
if entry.get('code') == exit_code:
treat_as = entry.get('treat_as')
description = BORG_EXIT_CODE_TO_DESCRIPTION.get(exit_code)
description_parenthetical = f' ({description})' if description else ''
if treat_as == 'error':
logger.error(
f'Treating exit code {exit_code} as an error, as per configuration',
)
return Exit_status.ERROR
# First try looking for the exit code in the borg_exit_codes configuration.
for entry in borg_exit_codes or ():
if entry.get('code') == exit_code:
treat_as = entry.get('treat_as')
if treat_as == 'warning':
logger.warning(
f'Treating exit code {exit_code} as a warning, as per configuration',
)
return Exit_status.WARNING
# If the exit code doesn't have explicit configuration, then fall back to the default
# behavior of treating Borg errors as errors and some Borg warnings as errors.
if exit_code in BORG_WARNING_EXIT_CODES_TREATED_AS_ERRORS:
logger.error(
f'Treating exit code {exit_code} as an error, as per borgmatic defaults',
)
return Exit_status.ERROR
return (
Exit_status.ERROR
if (
exit_code < 0
or (
exit_code >= BORG_ERROR_EXIT_CODE_START
and exit_code <= BORG_ERROR_EXIT_CODE_END
if treat_as == 'error':
logger.error(
f'Treating exit code {exit_code}{description_parenthetical} as an error, as per configuration',
)
)
else Exit_status.WARNING
return Exit_status.ERROR
if treat_as == 'warning':
logger.warning(
f'Treating exit code {exit_code}{description_parenthetical} as a warning, as per configuration',
)
return Exit_status.WARNING
# If the exit code doesn't have explicit configuration, then fall back to the default
# behavior of treating Borg errors as errors and some Borg warnings as errors.
if exit_code in BORG_WARNING_EXIT_CODES_TREATED_AS_ERRORS:
logger.error(
f'Treating exit code {exit_code}{description_parenthetical} as an error, as per borgmatic defaults',
)
return Exit_status.ERROR
return Exit_status.ERROR
if exit_code < 0 or (
exit_code >= BORG_ERROR_EXIT_CODE_START and exit_code <= BORG_ERROR_EXIT_CODE_END
):
return Exit_status.ERROR
logger.warning(
f'Treating exit code {exit_code}{description_parenthetical} as a warning, as per borgmatic defaults',
)
return Exit_status.WARNING
def command_for_process(process):
@@ -105,19 +130,41 @@ def output_buffers_for_process(process, exclude_stdouts):
)
BORG_LOG_LEVEL_ELEVATION_THRESHOLD = 10
def borg_json_log_line_to_record(line, log_level):
'''
Given a single Borg "--log-json"-style log line and a log level, return the line converted to a
logging.LogRecord instance. Return None if the line can't be parsed as JSON.
If Borg provides a log level in its JSON, prefer logging at that level. But if Borg doesn't
provide a log level—or the log level given to this function is just a little bit higher than
Borg's—elevate to that level. This supports use cases like elevating Borg's INFO level logs to
borgmatic's custom ANSWER level so that requested data shows up even at the default verbosity.
'''
with contextlib.suppress(json.JSONDecodeError, TypeError, KeyError, AttributeError):
log_data = json.loads(line)
log_type = log_data.get('type')
if log_type == 'log_message':
borg_log_level = logging._nameToLevel.get(log_data.get('levelname'))
log_level_delta = 0 if log_level is None else log_level - borg_log_level
if log_level_delta > 0 and log_level_delta < BORG_LOG_LEVEL_ELEVATION_THRESHOLD:
return logging.makeLogRecord(
dict(
levelno=log_level,
created=log_data.get('time'),
msg=log_data.get('message'),
levelname=logging.getLevelName(log_level),
name=log_data.get('name'),
)
)
return logging.makeLogRecord(
dict(
levelno=logging._nameToLevel.get(log_data.get('levelname')),
levelno=borg_log_level,
created=log_data.get('time'),
msg=log_data.get('message'),
levelname=log_data.get('levelname'),
@@ -166,9 +213,7 @@ def parse_log_line(line, log_level, elevate_stderr, borg_local_path, command):
came from stderr and the string "warning:" appears at the start of the log line. In that case,
just elevate the log level to a WARN.
'''
parsed_command = command.split(' ', 1) if isinstance(command, str) else command
if borg_local_path and parsed_command[0] == borg_local_path:
if command_is_borg(command, borg_local_path):
log_record = borg_json_log_line_to_record(line, log_level)
if log_record:
@@ -215,10 +260,13 @@ def read_lines(buffer, process, line_separator='\n'):
call to know when to read more lines. Otherwise, the generator will busywait if it's called in a
tight loop.
'''
data = ''
data = b''
encoded_separator = line_separator.encode()
separator_size = len(encoded_separator)
encoding = locale.getpreferredencoding()
while True:
chunk = os.read(buffer.fileno(), READ_CHUNK_SIZE).decode()
chunk = os.read(buffer.fileno(), READ_CHUNK_SIZE)
if not chunk: # EOF
# The process is still running, so we keep running too.
@@ -233,19 +281,19 @@ def read_lines(buffer, process, line_separator='\n'):
# Split the data into lines, holding back anything leftover that might
# be a partial line.
while True:
separator_position = data.find(line_separator)
separator_position = data.find(encoded_separator)
if separator_position == -1:
break
lines.append(data[:separator_position].rstrip())
data = data[separator_position + 1 :]
lines.append(data[:separator_position].decode(encoding))
data = data[separator_position + separator_size :]
yield tuple(lines)
# Yield any leftover data from the end of the buffer.
if data:
yield (data.rstrip(),)
yield (data.decode(encoding).rstrip(),)
Buffer_reader = collections.namedtuple(
@@ -266,8 +314,9 @@ def log_buffer_lines(
'''
Given a dict from buffer object to Buffer_reader, a dict from subprocess.Popen() instance to
Process_metadata instance, a requested output log level for stdout, Borg's local path, and
whether to capture stderr, read and log any ready output lines from the buffers. Additionally,
if the log level is None for any log record, then yield those log messages for capture.
whether to capture stderr, read and log any ready output lines from the buffers. Additionally,
for any log records with a log level that meets or exceeds the output log level, yield those log
messages for capture.
This function just does one "turn of the crank" of logging buffer output. It is intended to be
called repeatedly to continue to process buffers.
@@ -317,7 +366,11 @@ def log_buffer_lines(
last_lines=process_metadatas[reader.process].last_lines,
)
if log_record.levelno is None and process_metadatas[reader.process].capture:
if (
log_record.levelno is None
or output_log_level is None
or log_record.levelno >= output_log_level
) and process_metadatas[reader.process].capture:
yield log_record.getMessage()
@@ -376,8 +429,8 @@ def log_remaining_buffer_lines(
Given a dict from buffer object to Buffer_reader, a dict from subprocess.Popen() instance to
Process_metadata instance, a requested output log level for stdout, Borg's local path, and
whether to capture stderr, drain and log any remaining output lines from the buffers until
they're empty. Additionally, if the log level is None for any log record, then yield those log
messages for capture.
they're empty. Additionally, for any log records with a log level that meets or exceeds the
output log level, yield those log messages for capture.
'''
for output_buffer, reader in buffer_readers.items():
if not reader.process:
@@ -397,7 +450,11 @@ def log_remaining_buffer_lines(
),
)
if log_record.levelno is None and process_metadatas[reader.process].capture:
if (
log_record.levelno is None
or output_log_level is None
or log_record.levelno >= output_log_level
) and process_metadatas[reader.process].capture:
yield log_record.getMessage()
@@ -580,6 +637,7 @@ def execute_command(
def execute_command_and_capture_output(
full_command,
output_log_level=None,
input_file=None,
capture_stderr=False,
shell=False,
@@ -594,13 +652,15 @@ def execute_command_and_capture_output(
output (stdout) as a generator that yields one line at a time. The generator must be consumed in
order for the called command to execute.
If an input file descriptor is given, then pipe it to the command's stdin. If capture stderr is
True, then capture stderr in addition to stdout. If shell is True, execute the command within a
shell. If an environment variables dict is given, then pass it into the command. If a working
directory is given, use that as the present working directory when running the command. If a
Borg local path is given, and the command matches it (regardless of arguments), treat exit code
1 as a warning instead of an error. But if Borg exit codes are given as a sequence of exit code
configuration dicts, then use that configuration to decide what's an error and what's a warning.
If an output log level is given, then instead of suppressing log output, also output the
captured lines at the given log level. If an input file descriptor is given, then pipe it to
the command's stdin. If capture stderr is True, then capture stderr in addition to stdout. If
shell is True, execute the command within a shell. If an environment variables dict is given,
then pass it into the command. If a working directory is given, use that as the present working
directory when running the command. If a Borg local path is given, and the command matches it
(regardless of arguments), treat exit code 1 as a warning instead of an error. But if Borg exit
codes are given as a sequence of exit code configuration dicts, then use that configuration to
decide what's an error and what's a warning.
Raise subprocesses.CalledProcessError if an error occurs while running the command.
'''
@@ -612,7 +672,9 @@ def execute_command_and_capture_output(
command,
stdin=input_file,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE if capture_stderr else None,
stderr=subprocess.PIPE
if capture_stderr or command_is_borg(command, borg_local_path)
else None,
shell=shell,
env=environment,
cwd=working_directory,
@@ -626,22 +688,20 @@ def execute_command_and_capture_output(
raise
if error.output is not None:
yield from iter(error.output.decode().splitlines())
yield from iter(error.output.decode(locale.getpreferredencoding()).splitlines())
return
with borgmatic.logger.Log_prefix(None): # Log command output without any prefix.
captured_lines = log_outputs(
yield from log_outputs(
(process,),
(input_file,),
None,
output_log_level,
borg_local_path,
borg_exit_codes,
capture_stderr=capture_stderr,
)
yield from captured_lines
def execute_command_with_processes(
full_command,
@@ -704,12 +764,10 @@ def execute_command_with_processes(
raise
with borgmatic.logger.Log_prefix(None): # Log command output without any prefix.
captured_lines = log_outputs(
yield from log_outputs(
(*processes, command_process),
(input_file, output_file),
output_log_level,
borg_local_path,
borg_exit_codes,
)
yield from captured_lines
+33 -2
View File
@@ -1,6 +1,7 @@
import contextlib
import glob
import importlib
import itertools
import json
import logging
import os
@@ -19,6 +20,29 @@ def use_streaming(hook_config, config): # pragma: no cover
return False
MAXIMUM_CONFIG_SYMLINKS_TO_FOLLOW = 10
def resolve_config_path_symlinks(path):
'''
Given a path, resolve and yield each successive symlink until the final non-symlink target. If
the given path isn't a symlink, then just yield it.
Raise ValueError if we have to follow too many symlinks without getting to the final target.
'''
original_path = path
for _ in range(MAXIMUM_CONFIG_SYMLINKS_TO_FOLLOW):
yield os.path.abspath(path)
if not os.path.islink(path):
return
path = os.readlink(path)
raise ValueError(f'Too many symlinks to follow for configuration path: {original_path}')
def dump_data_sources(
hook_config,
config,
@@ -34,6 +58,9 @@ def dump_data_sources(
the archive. But skip this if the bootstrap store_config_files option is False or if this is a
dry run.
If any configuration paths are symlinks, then store each symlink along with any destination
paths as well.
Return an empty sequence, since there are no ongoing dump processes from this hook.
'''
if hook_config and hook_config.get('store_config_files') is False:
@@ -45,6 +72,10 @@ def dump_data_sources(
'manifest.json',
)
resolved_config_paths = tuple(
itertools.chain.from_iterable(resolve_config_path_symlinks(path) for path in config_paths)
)
if dry_run:
return []
@@ -54,7 +85,7 @@ def dump_data_sources(
json.dump(
{
'borgmatic_version': importlib.metadata.version('borgmatic'),
'config_paths': config_paths,
'config_paths': resolved_config_paths,
},
manifest_file,
)
@@ -67,7 +98,7 @@ def dump_data_sources(
),
)
for config_path in config_paths:
for config_path in resolved_config_paths:
borgmatic.hooks.data_source.config.inject_pattern(
patterns,
borgmatic.borg.pattern.Pattern(
+1 -1
View File
@@ -71,7 +71,7 @@ def get_datasets_to_backup(zfs_command, patterns):
)
# Skip datasets that are marked "canmount=off", because mounting their snapshots will
# result in completely empty mount points—thereby preventing us from backing them up.
if can_mount == 'on'
if can_mount != 'off'
),
key=lambda dataset: dataset.mount_point,
reverse=True,
+13 -8
View File
@@ -88,16 +88,17 @@ class Loki_log_handler(logging.Handler):
A log handler that sends logs to Loki.
'''
def __init__(self, url, send_logs, dry_run):
def __init__(self, url, send_logs, log_level, dry_run):
'''
Given a URL to send logs to, whether all borgmatic logs should be sent (or just explicitly
added messages from this hook), and whether this is a dry run, create an instance of
Loki_log_buffer.
added messages from this hook), the log level to use (influencing which logs get sent), and
whether this is a dry run, create an instance of Loki_log_buffer.
'''
super().__init__()
self.buffer = Loki_log_buffer(url, dry_run)
self.send_logs = send_logs
self.setLevel(log_level)
def emit(self, record):
'''
@@ -138,7 +139,7 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve
Add a handler to the root logger to regularly send the logs to Loki.
'''
url = hook_config.get('url')
loki = Loki_log_handler(url, hook_config.get('send_logs', False), dry_run)
loki = Loki_log_handler(url, hook_config.get('send_logs', False), monitoring_log_level, dry_run)
for key, value in hook_config.get('labels').items():
if value == '__hostname':
@@ -150,7 +151,9 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve
else:
loki.add_label(key, value)
logging.getLogger().addHandler(loki)
global_logger = logging.getLogger()
global_logger.addHandler(loki)
global_logger.setLevel(min(handler.level for handler in global_logger.handlers))
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
@@ -166,9 +169,11 @@ def destroy_monitor(hook_config, config, monitoring_log_level, dry_run):
'''
Remove the monitor handler that was added to the root logger.
'''
logger = logging.getLogger()
global_logger = logging.getLogger()
for handler in tuple(logger.handlers):
for handler in tuple(global_logger.handlers):
if isinstance(handler, Loki_log_handler):
handler.flush()
logger.removeHandler(handler)
global_logger.removeHandler(handler)
global_logger.setLevel(min(handler.level for handler in global_logger.handlers))
+14 -1
View File
@@ -22,6 +22,19 @@ def initialize_monitor(
'''
def convert_string_to_array(value):
value = '' if value is None else str(value)
items = []
for item in value.split(','):
stripped = item.strip()
if stripped:
items.append(stripped)
return items
PRIORITY_NAME_TO_ID = {
'max': 5,
'urgent': 5,
@@ -66,7 +79,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
'title': state_config.get('title'),
'message': state_config.get('message'),
'priority': PRIORITY_NAME_TO_ID.get(state_config.get('priority'), default_priority),
'tags': state_config.get('tags'),
'tags': convert_string_to_array(state_config.get('tags')),
}
try:
+1
View File
@@ -0,0 +1 @@
PORT=8080
+6 -2
View File
@@ -5,13 +5,17 @@ RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib
RUN pip install --break-system-packages --no-cache /app && borgmatic config generate && borgmatic config generate --destination /etc/borgmatic --split && chmod +r /etc/borgmatic/*.yaml
RUN mkdir /command-line \
&& borgmatic --help > /command-line/global.txt \
&& for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key import" "key change-passphrase" recreate borg; do \
&& for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" "config show" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key import" "key change-passphrase" recreate diff borg; do \
borgmatic $action --help > /command-line/${action/ /-}.txt; done
RUN /app/docs/fetch-contributors >> /contributors.html
FROM docker.io/node:22.4.0-alpine AS html
ARG ENVIRONMENT=production
ARG PORT=${PORT}
ARG FONT=${FONT}
RUN echo "FONT: ${FONT}"
WORKDIR /source
@@ -28,7 +32,7 @@ COPY --from=borgmatic /etc/borgmatic/options.json /source/docs/reference/configu
COPY --from=borgmatic /command-line/* /source/docs/_includes/borgmatic/command-line/
COPY --from=borgmatic /contributors.html /source/docs/_includes/borgmatic/contributors.html
COPY . /source
RUN NODE_ENV=${ENVIRONMENT} npx eleventy --input=/source/docs --output=/output
RUN NODE_ENV=${ENVIRONMENT} PORT=${PORT} FONT=${FONT} npx eleventy --input=/source/docs --output=/output
RUN npx -y pagefind --site /output
FROM docker.io/nginx:1.26.1-alpine
+3 -1
View File
@@ -1,5 +1,7 @@
module.exports = function() {
return {
environment: process.env.NODE_ENV || "development"
environment: process.env.NODE_ENV || "development",
port: process.env.PORT || 8080,
font: process.env.FONT || "custom"
};
};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<title>borgmatic{% if subtitle or title %} - {% endif %}{{ subtitle + ' - ' if subtitle}}{{ title }}</title>
{% endif %}
{%- set css %}
{% include 'index.css' %}
{% include 'index.css.njk' %}
{% include 'components/lists.css' %}
{% include 'components/external-links.css' %}
{% include 'components/minilink.css' %}
+1 -1
View File
@@ -21,7 +21,7 @@ headerClass: elv-header-default
{% set navPages = collections.all | eleventyNavigation %}
{% macro renderNavListItem(entry) -%}
<li{% if entry.url == page.url %} class="elv-toc-active"{% endif %}>
<a {% if entry.url %}href="{% if borgmatic.environment == "production" %}https://torsion.org/borgmatic{% else %}http://localhost:8080/borgmatic{% endif %}{{ entry.url | url }}"{% endif %}>{{ entry.title }}</a>
<a {% if entry.url %}href="{% if borgmatic.environment == "production" %}https://torsion.org/borgmatic{% else %}http://localhost:{{ borgmatic.port }}/borgmatic{% endif %}{{ entry.url | url }}"{% endif %}>{{ entry.title }}</a>
{%- if entry.children.length -%}
<ul>
{%- for child in entry.children %}{{ renderNavListItem(child) }}{% endfor -%}
+9 -3
View File
@@ -2,10 +2,12 @@ services:
traefik:
image: public.ecr.aws/docker/library/traefik:3.5.3
container_name: borgmatic-docs-traefik
environment:
- PORT=${PORT:-8080}
command:
- "--global.checkNewVersion=false"
- "--global.sendAnonymousUsage=false"
- "--entrypoints.web.address=:8080"
- "--entrypoints.web.address=:${PORT}"
- "--accesslog"
- "--accesslog.fields.headers.defaultmode=keep"
- "--providers.docker"
@@ -14,7 +16,7 @@ services:
- "--api.dashboard=false"
- "--log.level=WARN"
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:${PORT}:${PORT}"
volumes:
- ${CONTAINER_SOCKET_PATH:-/run/user/docker.sock}:/var/run/docker.sock:ro
docs:
@@ -37,6 +39,10 @@ services:
context: ..
args:
ENVIRONMENT: development
PORT: ${PORT:-8080}
FONT: ${FONT}
environment:
- PORT=${PORT:-8080}
message:
image: alpine
container_name: borgmatic-docs-message
@@ -44,6 +50,6 @@ services:
- sh
- -c
- |
echo; echo "You can view dev docs at http://localhost:8080/borgmatic/"; echo
echo; echo "You can view dev docs at http://localhost:${PORT}/borgmatic/"; echo
depends_on:
- docs
+1 -2
View File
@@ -14,7 +14,7 @@ import requests
def list_merged_pulls(url):
'''
Given a Gitea or GitHub API endpoint URL for pull requests, fetch and return the corresponding
Given a Forgejo or GitHub API endpoint URL for pull requests, fetch and return the corresponding
JSON for all such merged pull requests.
'''
response = requests.get(f'{url}?state=closed', headers={'Accept': 'application/json', 'Content-Type': 'application/json'})
@@ -36,7 +36,6 @@ def list_contributing_issues(url):
PULLS_API_ENDPOINT_URLS = (
'https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/pulls',
'https://api.github.com/repos/borgmatic-collective/borgmatic/pulls',
)
ISSUES_API_ENDPOINT_URL = 'https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/issues?state=all'
RECENT_CONTRIBUTORS_CUTOFF_DAYS = 365
@@ -82,7 +82,7 @@ before_actions:
option in the `hooks:` section of your configuration.
<span class="minilink minilink-addedin">Prior to version 1.7.0</span> Use
`before_create` or similar instead of `before_actions`, which was introduced in
`before_backup` or similar instead of `before_actions`, which was introduced in
borgmatic 1.7.0.
What this does is check if the `findmnt` command errors when probing for a
+15 -10
View File
@@ -12,10 +12,11 @@ decide how to respond. By default, Borg errors (and some warnings) result
in a borgmatic error, while Borg successes don't.
<span class="minilink minilink-addedin">New in borgmatic version 2.1.0</span>
borgmatic elevates most Borg warnings to errors by default. For instance, if a
source directory is missing during backup, Borg indicates that with a warning
exit code (`107`). And starting in borgmatic 2.1.0, that exit code is considered
an error, so you'll actually find out about missing files.
borgmatic elevates several Borg warnings to errors by default. For instance, if
borgmatic doesn't have permission to read a configured source directory during
backup, Borg indicates that with a warning exit code (`105`). And starting in
borgmatic 2.1.0, that exit code is considered an error, so you'll actually find
out about files that borgmatic can't read.
<span class="minilink minilink-addedin">With Borg version 1.4+</span> If the
default behavior isn't sufficient for your needs, you can customize how
@@ -23,7 +24,7 @@ borgmatic interprets [Borg's exit
codes](https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#message-ids).
For instance, this borgmatic configuration elevates a Borg warning about source files
changes during backup (exit code `100`)—and only those warnings—to
changing during backup (exit code `100`)—and only those warnings—to
errors:
```yaml
@@ -32,14 +33,15 @@ borg_exit_codes:
treat_as: error
```
The following configuration does that *and* treats Borg's backup file not found
(exit code `107`) as a warning:
The following configuration does that *and* squashes errors about Borg
encountering file permissions issues during backup (exit code `105`) to
warnings.
```yaml
borg_exit_codes:
- code: 100
treat_as: error
- code: 107
- code: 105
treat_as: warning
```
@@ -54,8 +56,11 @@ is not found:
terminating with warning status, rc 107
```
So if you want to configure borgmatic to treat this as an warning instead of an
error, the exit status to use is `107`.
So if you want to configure borgmatic's interpretation of this warning, the exit
status to use is `107`. Note however that in the particular case of missing
files, there's a separate [`source_directories_must_exist`
option](https://torsion.org/borgmatic/reference/configuration/#source_directories_must_exist-option)
that can catch such problems before Borg even runs.
<span class="minilink minilink-addedin">With Borg version 1.2 and earlier</span>
Older versions of Borg didn't support granular exit codes, but still
+40 -3
View File
@@ -190,8 +190,28 @@ This requires Docker (or Podman; see below) to be installed on your system.
This script assumes you have permission to run `docker`. If you don't, then
you may need to run with `sudo`.
### How to choose a different port
You can choose a different listening port in two ways:
#### 1. Modify the `.env` file
1. Open `docs/.env`.
2. Change `PORT=8080` to your desired port number (e.g., `PORT=3000`).
3. Run the development script: `scripts/dev-docs`.
#### 2. Use an environment variable
Alternatively, you can override the port directly from your terminal without
modifying any files:
```bash
PORT=3000 ./scripts/dev-docs
```
After you run the script, you can point your web browser at
http://localhost:8080/borgmatic/ to view the documentation with your changes.
http://localhost:8080/borgmatic/ (or your chosen port) to view the documentation
with your changes.
To close the documentation server, ctrl-C the script. Note that it does not
currently auto-reload, so you'll need to stop it and re-run it for any
@@ -205,7 +225,24 @@ borgmatic's developer build for documentation optionally supports using
[Podman](https://podman.io/) instead of Docker.
Setting up Podman is outside the scope of this documentation. But once you
install and configure Podman, then `scripts/dev-docs` should automatically use
Podman instead of Docker.
install and configure Podman, then `scripts/dev-docs` automatically uses Podman
instead of Docker and [Podman
Compose](https://github.com/containers/podman-compose) (if present) instead of
Docker Compose. However Podman works fine with either Podman Compose or Docker
Compose.
## Use of generative AI
Please do not use AI agents to modify this codebase. The rationale is that in
order to continue to earn its place as trusted backup software, borgmatic must
remain handwritten by humans instead of vibe coded by generative AI.
Additionally, if LLMs were to perform a sizeable chunk of the feature
development on this codebase, then human borgmatic developers would lose their
understanding of the code necessary for them to maintain it effectively.
Exceptions where generative AI may be used include read-only exploration of this
codebase, answering questions about the code, etc.
</span>
+19
View File
@@ -116,3 +116,22 @@ By default, borgmatic only logs to the console. But to enable simultaneous
syslog or file logging, see the [logging
documentation](https://torsion.org/borgmatic/reference/command-line/logging/)
for details.
## Finding differences between two archives
<span class="minilink minilink-addedin">New in borgmatic version
2.1.3</span>You can compare differences between two archives. For example:
```bash
borgmatic diff --archive latest --second-archive host-2023-01-02T04:06:07.080910
```
This shows the differences (file contents, user/group/mode) between the latest
archive and the second one supplied.
Note that, by default, `borgmatic diff` compares everything in the archives; that
is, patterns are _not_ taken into consideration. If you require this, supply the
`--only-patterns` flag.
See the [Borg](https://borgbackup.readthedocs.io/en/stable/usage/diff.html)
documentation for information on output format, what is compared, and more.
+107
View File
@@ -0,0 +1,107 @@
---
title: 📥 How to install borgmatic
eleventyNavigation:
key: 📥 Install borgmatic
parent: How-to guides
order: -1
---
### Prerequisites
Before installing borgmatic, first [install
Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least
version 1.1. (borgmatic does not install Borg automatically so as to avoid
conflicts with existing Borg installations.)
Then, [install uv](https://docs.astral.sh/uv/getting-started/installation/) as
the root user (with `sudo`) to make installing borgmatic easier without
impacting other Python applications on your system. For Debian, there is a
[third-party package for
uv](https://dario.griffo.io/posts/how-to-install-uv-debian/). On Ubuntu, there
is a [snap package](https://snapcraft.io/install/astral-uv/ubuntu). On Arch, you
can just install the `python-uv` package.
### Root install
If you want borgmatic to run with privileged access so it can backup your system
files, then install borgmatic as the root user by running the following
commands:
```bash
sudo uv tool update-shell
sudo uv tool install borgmatic
```
Check whether this worked with:
```bash
sudo su -
borgmatic --version
```
If borgmatic is properly installed, that should output your borgmatic version.
And if you'd also like `sudo borgmatic` to work as well, keep reading!
### Non-root install
If you only want to run borgmatic as a non-root user (without privileged file
access) *or* you want to make `sudo borgmatic` work so borgmatic runs as root,
then install borgmatic as a non-root user by running the following commands as
that user:
```bash
uv tool update-shell
uv tool install borgmatic
```
This should work even if you've also installed borgmatic as the root user.
Check whether this worked with:
```bash
borgmatic --version
```
If borgmatic is properly installed, that should output your borgmatic version.
You can also try `sudo borgmatic --version` if you intend to run borgmatic
with `sudo`. If that doesn't work, you may need to update your [sudoers
`secure_path` option](https://wiki.archlinux.org/title/Sudo).
### Other ways to install
Besides the approaches described above, there are several other options for
installing borgmatic:
#### <span data-pagefind-weight="7.0">Docker / Podman</span>
* [container image with scheduled backups](https://github.com/borgmatic-collective/docker-borgmatic) (+ Docker Compose files)
* [container image with multi-arch and Docker CLI support](https://github.com/modem7/docker-borgmatic)
#### Operating system packages
* [Debian](https://tracker.debian.org/pkg/borgmatic)
* [Ubuntu](https://launchpad.net/ubuntu/+source/borgmatic)
* [Fedora](https://bodhi.fedoraproject.org/updates/?search=borgmatic)
* [Gentoo](https://packages.gentoo.org/packages/app-backup/borgmatic)
* [Arch Linux](https://archlinux.org/packages/extra/any/borgmatic/)
* [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=borgmatic)
* [OpenBSD](https://openports.pl/path/sysutils/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?channel=unstable&show=borgmatic&query=borgmatic)
#### Etc.
* [stand-alone Linux binary](https://projects.torsion.org/borgmatic-collective/borgmatic/releases) (This is a beta feature!)
* [Ansible role](https://github.com/borgbase/ansible-role-borgbackup)
* [pipx](https://pipx.pypa.io/stable/)
## Next steps
* [Set up backups](https://torsion.org/borgmatic/how-to/set-up-backups/)
+21
View File
@@ -64,6 +64,27 @@ suppressed so as not to interfere with the captured JSON. Also note that JSON
output only shows up at the console and not in syslog.
### Getting configuration
<span class="minilink minilink-addedin">New in version 2.1.3</span> If you want
to consume borgmatic's computed configuration in your scripts, use the [`config
show`
action](https://torsion.org/borgmatic/reference/command-line/actions/config-show/).
Here's an example:
```bash
borgmatic config show --json
```
That outputs borgmatic's entire configuration as JSON with one array element per
configuration file.
Or you can ask for the value of a particular option:
```bash
borgmatic config show --option repositories --json
```
### Latest backups
All borgmatic actions that accept an `--archive` flag allow you to specify an
+6 -96
View File
@@ -1,104 +1,14 @@
---
title: 📥 How to set up backups
title: 📋 How to set up backups
eleventyNavigation:
key: 📥 Set up backups
key: 📋 Set up backups
parent: How-to guides
order: 0
---
To install borgmatic, first [install
Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least
version 1.1. (borgmatic does not install Borg automatically so as to avoid
conflicts with existing Borg installations.)
Then, [install pipx](https://pypa.github.io/pipx/installation/) as the root
user (with `sudo`) to make installing borgmatic easier without impacting other
Python applications on your system. If you have trouble installing pipx with
pip, then you can install a system package instead. E.g. on Ubuntu or Debian,
run:
```bash
sudo apt update
sudo apt install pipx
```
### Root install
If you want to run borgmatic on a schedule with privileged access to your
files, then you should install borgmatic as the root user by running the
following commands:
```bash
sudo pipx ensurepath
sudo pipx install borgmatic
```
Check whether this worked with:
```bash
sudo su -
borgmatic --version
```
If borgmatic is properly installed, that should output your borgmatic version.
And if you'd also like `sudo borgmatic` to work, keep reading!
### Non-root install
If you only want to run borgmatic as a non-root user (without privileged file
access) *or* you want to make `sudo borgmatic` work so borgmatic runs as root,
then install borgmatic as a non-root user by running the following commands as
that user:
```bash
pipx ensurepath
pipx install borgmatic
```
This should work even if you've also installed borgmatic as the root user.
Check whether this worked with:
```bash
borgmatic --version
```
If borgmatic is properly installed, that should output your borgmatic version.
You can also try `sudo borgmatic --version` if you intend to run borgmatic
with `sudo`. If that doesn't work, you may need to update your [sudoers
`secure_path` option](https://wiki.archlinux.org/title/Sudo).
### Other ways to install
Besides the approaches described above, there are several other options for
installing borgmatic:
#### <span data-pagefind-weight="7.0">Docker / Podman</span>
* [container image with scheduled backups](https://github.com/borgmatic-collective/docker-borgmatic) (+ Docker Compose files)
* [container image with multi-arch and Docker CLI support](https://github.com/modem7/docker-borgmatic)
#### Operating system packages
* [Debian](https://tracker.debian.org/pkg/borgmatic)
* [Ubuntu](https://launchpad.net/ubuntu/+source/borgmatic)
* [Fedora](https://bodhi.fedoraproject.org/updates/?search=borgmatic)
* [Gentoo](https://packages.gentoo.org/packages/app-backup/borgmatic)
* [Arch Linux](https://archlinux.org/packages/extra/any/borgmatic/)
* [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=borgmatic)
* [OpenBSD](https://openports.pl/path/sysutils/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?channel=unstable&show=borgmatic&query=borgmatic)
#### Etc.
* [Ansible role](https://github.com/borgbase/ansible-role-borgbackup)
* [uv tool install](https://docs.astral.sh/uv/)
Start by [installing
borgmatic](https://torsion.org/borgmatic/how-to/install-borgmatic/) if you haven't
already.
## Hosting providers
@@ -322,7 +232,7 @@ If you're using systemd instead of cron to run jobs, you can still configure
borgmatic to run automatically.
(If you installed borgmatic from [Other ways to
install](https://torsion.org/borgmatic/how-to/set-up-backups/#other-ways-to-install),
install](https://torsion.org/borgmatic/how-to/install-borgmatic/#other-ways-to-install),
you may already have borgmatic systemd service and timer files. If so, you may
be able to skip some of the steps below.)
+37 -8
View File
@@ -5,10 +5,25 @@ eleventyNavigation:
parent: How-to guides
order: 14
---
In general, all you should need to do to upgrade borgmatic if you've
[installed it with
pipx](https://torsion.org/borgmatic/how-to/set-up-backups/#installation)
is to run the following:
In general, all you should need to do to upgrade borgmatic if you've [installed
it with uv](https://docs.astral.sh/uv/) is to run the following:
```bash
sudo uv tool upgrade borgmatic
```
Omit `sudo` if you installed borgmatic as a non-root user. And if you
installed borgmatic *both* as root and as a non-root user, you'll need to
upgrade each installation independently.
### Upgrading from other installation methods
#### pipx
If you have borgmatic installed with
[pipx](https://pipx.pypa.io/latest/installation/), and you'd like to continue
using pipx, then you can upgrade borgmatic with:
```bash
sudo pipx upgrade borgmatic
@@ -18,16 +33,30 @@ Omit `sudo` if you installed borgmatic as a non-root user. And if you
installed borgmatic *both* as root and as a non-root user, you'll need to
upgrade each installation independently.
But if you'd like to switch your borgmatic install from pipx to
[uv](https://docs.astral.sh/uv/), uninstall borgmatic with pipx (`sudo pipx
uninstall borgmatic`) and then [install borgmatic with
uv](https://torsion.org/borgmatic/how-to/install-borgmatic/).
Either one should work just fine. uv is just faster than pipx and also used for
borgmatic
[development](https://torsion.org/borgmatic/how-to/develop-on-borgmatic/).
#### pip install
If you originally installed borgmatic with `sudo pip3 install --user`, you can
uninstall it first with `sudo pip3 uninstall borgmatic` and then [install it
again with
pipx](https://torsion.org/borgmatic/how-to/set-up-backups/#installation),
uv](https://torsion.org/borgmatic/how-to/install-borgmatic/),
which should better isolate borgmatic from your other Python applications.
But if you [installed borgmatic without pipx or
pip3](https://torsion.org/borgmatic/how-to/set-up-backups/#other-ways-to-install),
then your upgrade method may be different.
#### Etc.
If you installed borgmatic [some other
way](https://torsion.org/borgmatic/how-to/install-borgmatic/#other-ways-to-install),
then your upgrade method may be different.
### Upgrading your configuration
@@ -0,0 +1,17 @@
---
title: config show
eleventyNavigation:
key: config show
parent: 🎬 Actions
---
{% include snippet/command-line/sample.md %}
```
{% include borgmatic/command-line/config-show.txt %}
```
## Related documentation
* [Scripting borgmatic](https://torsion.org/borgmatic/how-to/monitor-your-backups/#scripting-borgmatic)
@@ -0,0 +1,16 @@
---
title: diff
eleventyNavigation:
key: diff
parent: 🎬 Actions
---
{% include snippet/command-line/sample.md %}
```
{% include borgmatic/command-line/diff.txt %}
```
## Related documentation
* [Finding differences](https://torsion.org/borgmatic/how-to/inspect-your-backups/#finding-differences-between-two-archives)
@@ -97,4 +97,3 @@ option for limiting the archives used for the `check` action was a separate
`prefix` in the `consistency` section. Both of these options are deprecated in
favor of the auto-matching behavior (or `match_archives`/`--match-archives`)
in newer versions of borgmatic.
@@ -12,11 +12,15 @@ list of `commands:` in your borgmatic configuration file. For example:
```yaml
commands:
- before: action
when: [create]
when: [check] # This is an inline YAML sequence.
run:
- echo "Before create!"
- before: action
when: [create, prune] # Also an inline YAML sequence.
run:
- echo "Before create or prune!"
- after: action
when:
when: # Multi-line YAML sequence, equivalent to "[create, prune]".
- create
- prune
run:
@@ -29,7 +33,7 @@ commands:
Each command in the `commands:` list has the following options:
* `before` or `after`: Name for the point in borgmatic's execution that the commands should be run before or after, one of:
* `action` runs before or after each action for each repository. This replaces the deprecated `before_create`, `after_prune`, etc.
* `action` runs before or after each action for each repository. This replaces the deprecated `before_backup`, `after_prune`, etc.
* `repository` runs before or after all actions for each repository. This replaces the deprecated `before_actions` and `after_actions`.
* `configuration` runs before or after all actions and repositories in the current configuration file.
* `everything` runs before or after all configuration files. Errors here do not trigger `error` hooks or the `fail` state in monitoring hooks. This replaces the deprecated `before_everything` and `after_everything`.
+3
View File
@@ -317,3 +317,6 @@ a default location.
This will output the merged configuration as borgmatic sees it, which can be
helpful for understanding how your includes work in practice.
Also see the [`config show`
action](https://torsion.org/borgmatic/reference/command-line/actions/config-show/).
@@ -12,12 +12,11 @@ us today such as: Telegram, Discord, Slack, Amazon SNS, Gotify, etc."
Depending on how you installed borgmatic, it may not have come with Apprise.
For instance, if you originally [installed borgmatic with
pipx](https://torsion.org/borgmatic/how-to/set-up-backups/),
uv](https://torsion.org/borgmatic/how-to/install-borgmatic/),
run the following to install Apprise so borgmatic can use it:
```bash
sudo pipx uninstall borgmatic
sudo pipx install borgmatic[Apprise]
sudo uv tool install borgmatic[Apprise]
```
Omit `sudo` if borgmatic is installed as a non-root user.
+3 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "borgmatic"
version = "2.1.1"
version = "2.1.4"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
@@ -44,7 +44,7 @@ namespaces = false
[tool.pytest.ini_options]
testpaths = "tests"
addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end"
addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end --timeout=120"
[tool.ruff]
line-length = 100
@@ -100,6 +100,7 @@ ignore = [
"S404", # subprocess import
"SIM115", # open() without context manager
"SIM905", # split() on literal string
"TRY004", # type check and raise something other than TypeError
]
[tool.ruff.lint.flake8-quotes]
+11 -5
View File
@@ -2,11 +2,17 @@
set -e
USER_PODMAN_SOCKET_PATH=/run/user/$UID/podman/podman.sock
if [ -e "$USER_PODMAN_SOCKET_PATH" ]; then
export DOCKER_HOST="unix://$USER_PODMAN_SOCKET_PATH"
if command -v podman-compose >/dev/null 2>&1; then
COMPOSE_CMD="podman-compose"
export CONTAINER_SOCKET_PATH="${XDG_RUNTIME_DIR:-/run/user/$UID}/podman/podman.sock"
else
COMPOSE_CMD="docker-compose"
USER_PODMAN_SOCKET_PATH=/run/user/$UID/podman/podman.sock
export CONTAINER_SOCKET_PATH="$USER_PODMAN_SOCKET_PATH"
if [ -e "$USER_PODMAN_SOCKET_PATH" ]; then
export DOCKER_HOST="unix://$USER_PODMAN_SOCKET_PATH"
fi
fi
BUILDKIT_PROGRESS=plain docker-compose --file docs/docker-compose.yaml up --build --force-recreate
BUILDKIT_PROGRESS=plain $COMPOSE_CMD --file docs/docker-compose.yaml up --build --force-recreate
+1 -1
View File
@@ -9,7 +9,7 @@ if [ -e "$USER_PODMAN_SOCKET_PATH" ]; then
export CONTAINER_SOCKET_PATH="$USER_PODMAN_SOCKET_PATH"
fi
podman build --tag borgmatic-release-docs --file docs/Dockerfile .
podman build --tag borgmatic-release-docs --file docs/Dockerfile --env FONT=plain .
docs_container_id=$(podman create borgmatic-release-docs)
podman cp "$docs_container_id":/usr/share/nginx/html - > borgmatic-docs-dump.tar
tar xf borgmatic-docs-dump.tar
+15
View File
@@ -43,6 +43,16 @@ twine upload -r pypi --username __token__ "$wheel_path"
scripts/export-docs-from-image
docs_path=dist/borgmatic-docs.tar.gz
# Build stand-alone binary.
uv venv --clear binary
source binary/bin/activate
uv pip install -r binary_requirements.txt
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
standalone_binary_path="dist/borgmatic-${version}-binary-linux-glibc-x86_64"
mv borgmatic.bin "$standalone_binary_path"
# Set release changelogs on projects.torsion.org and GitHub.
release_changelog="$(cat NEWS | sed '/^$/q' | grep -v '^\S')"
escaped_release_changelog="$(echo "$release_changelog" | sed -z 's/\n/\\n/g' | sed -z 's/\"/\\"/g')"
@@ -68,6 +78,11 @@ curl --silent --request POST \
--header "Authorization: token $projects_token" \
--header "Accept: application/json" \
--form attachment=@"$docs_path"
curl --silent --request POST \
"https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/releases/$release_id/assets?name=$(basename $standalone_binary_path)" \
--header "Authorization: token $projects_token" \
--header "Accept: application/json" \
--form attachment=@"$standalone_binary_path"
github-release create --token="$github_token" --owner=witten --repo=borgmatic --tag="$version" --target_commit="main" \
--name="borgmatic $version" --body="$release_changelog"
+16 -15
View File
@@ -1,27 +1,28 @@
apprise==1.9.5
attrs==25.4.0
certifi==2025.10.5
charset-normalizer==3.4.4
apprise==1.9.9
attrs==26.1.0
certifi==2026.2.25
charset-normalizer==3.4.6
click>=8.1.8
codespell==2.4.1
coverage==7.11.0
flexmock==0.12.2
codespell==2.4.2
coverage==7.13.5
flexmock==0.13.0
idna==3.11
iniconfig==2.3.0
jsonschema==4.25.1
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
markdown==3.9
markdown==3.10.2
oauthlib==3.3.1
packaging==25.0
packaging==26.0
pluggy==1.6.0
pygments==2.19.2
pytest==8.4.2
pytest-cov==7.0.0
pygments==2.20.0
pytest==9.0.2
pytest-cov==7.1.0
pytest-timeout==2.4.0
pyyaml>5.0.0
referencing==0.37.0
requests==2.32.5
requests==2.33.1
requests-oauthlib==2.0.0
rpds-py==0.28.0
rpds-py==0.30.0
ruamel-yaml>0.15.0
typing-extensions==4.15.0
urllib3==2.6.3
+1 -1
View File
@@ -35,7 +35,7 @@ BUILTIN_DATASETS = (
'used': '256K',
'avail': '23.7M',
'refer': '25K',
'canmount': 'on',
'canmount': 'noauto',
'mountpoint': '/e2e/pool/dataset',
},
)
+1 -1
View File
@@ -44,7 +44,7 @@ services:
MONGO_INITDB_ROOT_PASSWORD: test2
command: docker-entrypoint.sh --port=27018
tests:
image: docker.io/alpine:3.22
image: docker.io/alpine:3.23
environment:
TEST_CONTAINER: true
volumes:
@@ -69,6 +69,7 @@ class Background_web_server:
self.thread.join()
FINISH = 1
START_AND_FINISH = 2
START_LOG_AND_FINISH = 3
@@ -94,7 +95,7 @@ START_LOG_AND_FINISH = 3
),
(
'loki:\n url: http://localhost:12345/loki/api/v1/push\n labels:\n app: borgmatic\n send_logs: true',
START_AND_FINISH,
FINISH,
),
(
'loki:\n url: http://localhost:12345/loki/api/v1/push\n labels:\n app: borgmatic',
+2 -5
View File
@@ -35,7 +35,7 @@ def assert_command_does_not_duplicate_flags(command, *args, **kwargs):
if '--json' in command:
return '{}'
return None
return ''
def fuzz_argument(arguments, argument_name):
@@ -159,10 +159,7 @@ def test_make_repo_list_command_does_not_duplicate_flags_or_raise():
def test_display_archives_info_command_does_not_duplicate_flags_or_raise():
arguments = borgmatic.commands.arguments.parse_arguments({}, 'info')['info']
flexmock(borgmatic.borg.info).should_receive('execute_command_and_capture_output').replace_with(
assert_command_does_not_duplicate_flags,
)
flexmock(borgmatic.borg.info).should_receive('execute_command').replace_with(
assert_command_does_not_duplicate_flags,
lambda command, *args, **kwargs: iter((assert_command_does_not_duplicate_flags(command),)),
)
for argument_name in dir(arguments):
@@ -292,6 +292,12 @@ def test_render_configuration_converts_configuration_to_yaml_string():
assert yaml_string == 'foo: bar\n'
def test_render_configuration_strips_ruamel_yaml_end_of_document_marker():
yaml_string = module.render_configuration(33)
assert yaml_string == '33\n'
def test_write_configuration_does_not_raise():
flexmock(os.path).should_receive('exists').and_return(False)
flexmock(os).should_receive('makedirs')
+1 -1
View File
@@ -10,7 +10,7 @@ MAXIMUM_LINE_LENGTH = 80
def test_schema_line_length_stays_under_limit():
schema_file = open(borgmatic.config.validate.schema_filename())
for line in schema_file.readlines():
for line in schema_file:
assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH
+20 -11
View File
@@ -8,7 +8,7 @@ from borgmatic.hooks.monitoring import loki as module
def test_loki_log_handler_raw_with_send_logs_posts_to_server_after_buffer_full():
handler = module.Loki_log_handler(flexmock(), send_logs=True, dry_run=False)
handler = module.Loki_log_handler(flexmock(), send_logs=True, log_level=10, dry_run=False)
flexmock(module.requests).should_receive('post').and_return(
flexmock(raise_for_status=lambda: ''),
).once()
@@ -18,7 +18,7 @@ def test_loki_log_handler_raw_with_send_logs_posts_to_server_after_buffer_full()
def test_loki_log_handler_raw_without_send_logs_posts_to_server_without_buffering():
handler = module.Loki_log_handler(flexmock(), send_logs=False, dry_run=False)
handler = module.Loki_log_handler(flexmock(), send_logs=False, log_level=10, dry_run=False)
flexmock(module.requests).should_receive('post').and_return(
flexmock(raise_for_status=lambda: ''),
).times(3)
@@ -28,7 +28,7 @@ def test_loki_log_handler_raw_without_send_logs_posts_to_server_without_bufferin
def test_loki_log_handler_raw_post_failure_does_not_raise():
handler = module.Loki_log_handler(flexmock(), send_logs=True, dry_run=False)
handler = module.Loki_log_handler(flexmock(), send_logs=True, log_level=10, dry_run=False)
flexmock(module.requests).should_receive('post').and_return(
flexmock(raise_for_status=lambda: (_ for _ in ()).throw(requests.RequestException())),
).once()
@@ -46,7 +46,10 @@ def test_initialize_monitor_replaces_labels():
'labels': {'hostname': '__hostname', 'config': '__config', 'config_full': '__config_path'},
}
config_filename = '/mock/path/test.yaml'
module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run=False)
flexmock(module.logging.getLogger()).should_receive('setLevel')
module.initialize_monitor(
hook_config, flexmock(), config_filename, monitoring_log_level=10, dry_run=False
)
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, module.Loki_log_handler):
@@ -63,11 +66,12 @@ def test_initialize_monitor_adds_log_handler():
Assert that calling initialize_monitor adds our logger to the root logger.
'''
hook_config = {'url': 'http://localhost:3100/loki/api/v1/push', 'labels': {'app': 'borgmatic'}}
flexmock(module.logging.getLogger()).should_receive('setLevel')
module.initialize_monitor(
hook_config,
flexmock(),
config_filename='test.yaml',
monitoring_log_level=flexmock(),
monitoring_log_level=10,
dry_run=True,
)
@@ -99,7 +103,10 @@ def test_ping_monitor_sends_log_message():
flexmock(module.requests).should_receive('post').replace_with(post)
module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run=False)
flexmock(module.logging.getLogger()).should_receive('setLevel')
module.initialize_monitor(
hook_config, flexmock(), config_filename, monitoring_log_level=10, dry_run=False
)
module.ping_monitor(
hook_config,
flexmock(),
@@ -108,7 +115,7 @@ def test_ping_monitor_sends_log_message():
flexmock(),
dry_run=False,
)
module.destroy_monitor(hook_config, flexmock(), flexmock(), dry_run=False)
module.destroy_monitor(hook_config, flexmock(), monitoring_log_level=10, dry_run=False)
assert post_called
@@ -121,9 +128,11 @@ def test_destroy_monitor_removes_log_handler():
config_filename = 'test.yaml'
flexmock(module.requests).should_receive('post').never()
module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run=False)
module.destroy_monitor(hook_config, flexmock(), flexmock(), dry_run=False)
flexmock(module.logging.getLogger()).should_receive('setLevel')
module.initialize_monitor(
hook_config, flexmock(), config_filename, monitoring_log_level=10, dry_run=False
)
module.destroy_monitor(hook_config, flexmock(), monitoring_log_level=10, dry_run=False)
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, module.Loki_log_handler):
raise AssertionError()
assert not isinstance(handler, module.Loki_log_handler)
+44 -33
View File
@@ -26,6 +26,19 @@ def test_read_lines_yields_single_line_longer_than_chunk_size():
)
def test_read_lines_yields_single_line_with_multibyte_unicode_character_spanning_chunk_boundary():
# In case it's not clear, "ñ" is a multi-byte UTF-8 character. The "a" shifts it over one byte
# so it straddles the chunk boundary.
process = subprocess.Popen(['echo', 'aññññññññññññññññññññññññññññññ'], stdout=subprocess.PIPE)
assert tuple(flexmock(module, READ_CHUNK_SIZE=16).read_lines(process.stdout, process)) == (
(),
(),
(),
('aññññññññññññññññññññññññññññññ',),
)
def test_read_lines_yields_multiple_lines():
process = subprocess.Popen(['echo', 'hi\nthere'], stdout=subprocess.PIPE)
@@ -92,18 +105,15 @@ def test_log_outputs_logs_each_line_separately():
(),
).and_return((there_process.stdout,))
assert (
tuple(
module.log_outputs(
(hi_process, there_process),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
borg_exit_codes=None,
)
assert tuple(
module.log_outputs(
(hi_process, there_process),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
borg_exit_codes=None,
)
== ()
)
) == ('there',)
def test_log_outputs_logs_stderr_as_error():
@@ -127,18 +137,15 @@ def test_log_outputs_logs_stderr_as_error():
(),
).and_return((echo_process.stdout, echo_process.stderr))
assert (
tuple(
module.log_outputs(
(echo_process,),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
borg_exit_codes=None,
)
assert tuple(
module.log_outputs(
(echo_process,),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
borg_exit_codes=None,
)
== ()
)
) == ('error',)
def test_log_outputs_skips_logs_for_process_with_none_stdout():
@@ -167,18 +174,15 @@ def test_log_outputs_skips_logs_for_process_with_none_stdout():
(),
).and_return((there_process.stdout,))
assert (
tuple(
module.log_outputs(
(hi_process, there_process),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
borg_exit_codes=None,
)
assert tuple(
module.log_outputs(
(hi_process, there_process),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
borg_exit_codes=None,
)
== ()
)
) == ('there',)
def test_log_outputs_returns_output_without_logging_for_output_log_level_none():
@@ -548,3 +552,10 @@ def test_log_outputs_with_unfinished_process_re_polls():
)
== ()
)
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)
assert tuple(module.read_lines(process.stdout, process)) == (('\xc4pple',),)
+79
View File
@@ -0,0 +1,79 @@
from flexmock import flexmock
import borgmatic.logger
from borgmatic.actions.config import show as module
def test_run_show_with_single_configuration_file_does_not_separate_output():
log_lines = []
borgmatic.logger.add_custom_log_levels()
def fake_logger_answer(message):
log_lines.append(message)
flexmock(module.logger).should_receive('answer').replace_with(fake_logger_answer)
show_arguments = flexmock(option=None, json=False)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').and_return(
'output'
)
module.run_show(show_arguments, configs={'test.yaml': {}})
assert log_lines == ['output']
def test_run_show_with_multiple_configuration_files_separates_output():
log_lines = []
borgmatic.logger.add_custom_log_levels()
def fake_logger_answer(message):
log_lines.append(message)
flexmock(module.logger).should_receive('answer').replace_with(fake_logger_answer)
show_arguments = flexmock(option=None, json=False)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').and_return(
'output'
).and_return('other')
module.run_show(show_arguments, configs={'test.yaml': {}, 'other.yaml': {}})
assert log_lines == ['---', 'output', '---', 'other']
def test_run_show_with_option_limits_output():
log_lines = []
borgmatic.logger.add_custom_log_levels()
def fake_logger_answer(message):
log_lines.append(message)
flexmock(module.logger).should_receive('answer').replace_with(fake_logger_answer)
show_arguments = flexmock(option='foo', json=False)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').with_args(
33
).and_return('33')
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').with_args(
None
).and_return('null')
module.run_show(show_arguments, configs={'test.yaml': {'foo': 33, 'bar': 44}, 'other.yaml': {}})
assert log_lines == ['---', '33', '---', 'null']
def test_run_show_with_json_outputs_json():
flexmock(borgmatic.logger).should_receive('add_custom_log_levels')
show_arguments = flexmock(option=None, json=True)
flexmock(module.sys.stdout).should_receive('write').with_args(
'[{"foo": 33}, {"bar": 44}]'
).once()
module.run_show(show_arguments, configs={'test.yaml': {'foo': 33}, 'other.yaml': {'bar': 44}})
def test_run_show_with_json_and_option_limits_json():
flexmock(borgmatic.logger).should_receive('add_custom_log_levels')
show_arguments = flexmock(option='foo', json=True)
flexmock(module.sys.stdout).should_receive('write').with_args('[33, null]').once()
module.run_show(show_arguments, configs={'test.yaml': {'foo': 33}, 'other.yaml': {'bar': 44}})
+2 -2
View File
@@ -5,13 +5,13 @@ from borgmatic.actions.config import validate as module
def test_run_validate_does_not_raise():
validate_arguments = flexmock(show=False)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration')
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').and_return('')
module.run_validate(validate_arguments, flexmock())
def test_run_validate_with_show_does_not_raise():
validate_arguments = flexmock(show=True)
flexmock(module.borgmatic.config.generate).should_receive('render_configuration')
flexmock(module.borgmatic.config.generate).should_receive('render_configuration').and_return('')
module.run_validate(validate_arguments, {'test.yaml': flexmock(), 'other.yaml': flexmock()})
+217 -23
View File
@@ -986,6 +986,65 @@ def test_collect_spot_check_source_paths_uses_working_directory():
) == ('foo', 'bar')
def test_collect_spot_check_source_paths_deduplicates_borg_output_paths():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': True},
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
flexmock(),
)
flexmock(module.borgmatic.actions.pattern).should_receive('collect_patterns').and_return(
(Pattern('collected'),),
)
flexmock(module.borgmatic.actions.pattern).should_receive('process_patterns').with_args(
(
Pattern('collected', source=module.borgmatic.borg.pattern.Pattern_source.HOOK),
Pattern('extra.yaml', source=module.borgmatic.borg.pattern.Pattern_source.INTERNAL),
),
config=object,
working_directory=None,
).and_return(
[Pattern('foo'), Pattern('bar')],
)
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
dry_run=True,
repository_path='repo',
config=object,
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version=object,
global_arguments=object,
borgmatic_runtime_directory='/run/borgmatic',
local_path=object,
remote_path=object,
stream_processes=True,
).and_return((('borg', 'create'), ('repo::archive',), flexmock()))
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock(),
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).and_yield(
'warning: stuff',
'- /etc/path',
'+ /etc/other',
'? /nope',
'- /etc/path',
)
flexmock(module.os.path).should_receive('isfile').and_return(True)
assert module.collect_spot_check_source_paths(
repository={'path': 'repo'},
config={'working_directory': '/'},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
bootstrap_config_paths=('extra.yaml',),
) == ('/etc/path', '/etc/other')
def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
@@ -1002,8 +1061,9 @@ def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
'hash2 /bar',
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'nothash2', 'path': 'bar'},
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{},
{'xxh64': 'nothash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
@@ -1045,8 +1105,8 @@ def test_compare_spot_check_hashes_handles_weird_backslashed_hashes_from_xxh64su
'\\hash2 /bar',
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'nothash2', 'path': 'bar'},
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'nothash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
@@ -1088,8 +1148,8 @@ def test_compare_spot_check_hashes_handles_incorrect_path_names_from_xxh64sum():
'hash2 /bar/wrong/path',
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'nothash2', 'path': 'bar'},
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'nothash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
@@ -1115,6 +1175,59 @@ def test_compare_spot_check_hashes_handles_incorrect_path_names_from_xxh64sum():
) == ('/bar',)
def test_compare_spot_check_hashes_with_xxh64sum_failure_falls_back_to_individual_file_hashing():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
)
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('islink').and_return(False)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo', '/bar'), working_directory=None).and_raise(
module.subprocess.CalledProcessError(1, 'wtf')
)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo'), working_directory=None).and_raise(
module.subprocess.CalledProcessError(1, 'wtf')
).once()
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/bar'), working_directory=None).and_yield(
'hash2 /bar',
).once()
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'hash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
repository={'path': 'repo'},
archive='archive',
config={
'checks': [
{
'name': 'archives',
'frequency': '2 weeks',
},
{
'name': 'spot',
'data_sample_percentage': 50,
},
],
},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
source_paths=('/foo', '/bar', '/baz', '/quux'),
) == ('/foo',)
def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
@@ -1131,8 +1244,8 @@ def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes(
'hash2 bar',
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'nothash2', 'path': 'bar'},
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'nothash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
@@ -1174,8 +1287,8 @@ def test_compare_spot_check_hashes_handles_data_sample_percentage_above_100():
'hash2 /bar',
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'nothash1', 'path': 'foo'},
{'xxh64': 'nothash2', 'path': 'bar'},
{'xxh64': 'nothash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'nothash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
@@ -1217,8 +1330,8 @@ def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
working_directory=None,
).and_yield('hash1 /foo', 'hash2 /bar')
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'nothash2', 'path': 'bar'},
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'nothash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
@@ -1257,7 +1370,7 @@ def test_compare_spot_check_hashes_considers_path_missing_from_archive_as_not_ma
'hash2 /bar',
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'}
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''}
)
assert module.compare_spot_check_hashes(
@@ -1279,6 +1392,50 @@ def test_compare_spot_check_hashes_considers_path_missing_from_archive_as_not_ma
) == ('/bar',)
def test_compare_spot_check_hashes_skips_hardlink_path_in_archive():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
)
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('islink').and_return(False)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo', '/bar', '/link'), working_directory=None).and_yield(
'hash1 /foo',
'hash2 /bar',
'hash1 /link',
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'hash2', 'path': 'bar', 'linktarget': ''},
{'xxh64': '', 'path': 'link', 'linktarget': 'foo'},
)
assert (
module.compare_spot_check_hashes(
repository={'path': 'repo'},
archive='archive',
config={
'checks': [
{
'name': 'spot',
'data_sample_percentage': 100,
},
],
},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
source_paths=('/foo', '/bar', '/link'),
)
== ()
)
def test_compare_spot_check_hashes_considers_symlink_path_as_not_matching():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
@@ -1293,8 +1450,45 @@ def test_compare_spot_check_hashes_considers_symlink_path_as_not_matching():
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo'), working_directory=None).and_yield('hash1 /foo')
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'hash2', 'path': 'bar'},
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'hash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
repository={'path': 'repo'},
archive='archive',
config={
'checks': [
{
'name': 'spot',
'data_sample_percentage': 50,
},
],
},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
source_paths=('/foo', '/bar', '/baz', '/quux'),
) == ('/bar',)
def test_compare_spot_check_hashes_considers_borg_2_symlink_path_as_not_matching():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
)
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('/foo').and_return(False)
flexmock(module.os.path).should_receive('islink').with_args('/bar').and_return(True)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo'), working_directory=None).and_yield('hash1 /foo')
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo', 'target': ''},
{'xxh64': 'hash2', 'path': 'bar', 'target': ''},
)
assert module.compare_spot_check_hashes(
@@ -1330,8 +1524,8 @@ def test_compare_spot_check_hashes_considers_non_existent_path_as_not_matching()
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo'), working_directory=None).and_yield('hash1 /foo')
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'hash2', 'path': 'bar'},
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'hash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
@@ -1376,11 +1570,11 @@ def test_compare_spot_check_hashes_with_too_many_paths_feeds_them_to_commands_in
'hash4 /quux',
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'hash2', 'path': 'bar'},
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'hash2', 'path': 'bar', 'linktarget': ''},
).and_yield(
{'xxh64': 'hash3', 'path': 'baz'},
{'xxh64': 'nothash4', 'path': 'quux'},
{'xxh64': 'hash3', 'path': 'baz', 'linktarget': ''},
{'xxh64': 'nothash4', 'path': 'quux', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
@@ -1423,8 +1617,8 @@ def test_compare_spot_check_hashes_uses_working_directory_to_access_source_paths
'hash2 bar',
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'nothash2', 'path': 'bar'},
{'xxh64': 'hash1', 'path': 'foo', 'linktarget': ''},
{'xxh64': 'nothash2', 'path': 'bar', 'linktarget': ''},
)
assert module.compare_spot_check_hashes(
+63
View File
@@ -0,0 +1,63 @@
from flexmock import flexmock
from borgmatic.actions import diff as module
def test_run_diff_calls_borg_diff():
flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return(
'archive'
).and_return('archive2')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
flexmock(),
)
flexmock(module.borgmatic.borg.diff).should_receive('diff').once()
module.borgmatic.actions.diff.run_diff(
repository={'path': 'repo'},
config={},
local_borg_version=None,
diff_arguments=flexmock(
archive='archive',
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive=None,
only_patterns=False,
),
global_arguments=flexmock(),
local_path=None,
remote_path=None,
)
def test_run_diff_with_only_patterns():
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
flexmock(),
)
flexmock(module.borgmatic.actions.pattern).should_receive('collect_patterns').once().and_return(
[]
)
flexmock(module.borgmatic.actions.pattern).should_receive('process_patterns').once().and_return(
[]
)
flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return(
'archive'
).and_return('archive2')
flexmock(module.borgmatic.borg.diff).should_receive('diff').once()
module.borgmatic.actions.diff.run_diff(
repository={'path': 'repo'},
config={},
local_borg_version=None,
diff_arguments=flexmock(
archive='archive',
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive=None,
only_patterns=True,
),
global_arguments=flexmock(),
local_path=None,
remote_path=None,
)
+1
View File
@@ -521,6 +521,7 @@ def test_collect_dumps_from_archive_with_dumps_metadata_parses_it():
flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return(
flexmock(stdout=flexmock(read=lambda: b''))
)
flexmock(module.locale).should_receive('getpreferredencoding').and_return('UTF-8')
dumps_metadata = [
module.Dump('postgresql_databases', 'foo'),
module.Dump('postgresql_databases', 'bar', 'host', 1234),
+10 -10
View File
@@ -24,7 +24,7 @@ def insert_execute_command_mock(
environment=None,
working_directory=working_directory,
borg_local_path=command[0],
borg_exit_codes=borg_exit_codes,
borg_exit_codes=(borg_exit_codes or []) + [{'code': 1, 'treat_as': 'error'}],
).once()
@@ -335,7 +335,7 @@ def test_check_archives_with_progress_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -371,7 +371,7 @@ def test_check_archives_with_log_json_and_progress_passes_through_both_to_borg()
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -407,7 +407,7 @@ def test_check_archives_with_repair_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -443,7 +443,7 @@ def test_check_archives_with_log_json_and_repair_passes_through_both_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -479,7 +479,7 @@ def test_check_archives_with_max_duration_flag_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -515,7 +515,7 @@ def test_check_archives_with_max_duration_option_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -689,7 +689,7 @@ def test_check_archives_with_max_duration_flag_overrides_max_duration_option():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -854,7 +854,7 @@ def test_check_archives_with_local_path_calls_borg_via_local_path():
def test_check_archives_with_exit_codes_calls_borg_using_them():
checks = {'repository'}
borg_exit_codes = flexmock()
borg_exit_codes = [{'code': 101, 'treat_as': 'error'}]
config = {'borg_exit_codes': borg_exit_codes}
flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
@@ -1026,7 +1026,7 @@ def test_check_archives_with_match_archives_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
+55 -1
View File
@@ -357,7 +357,8 @@ DEFAULT_ARCHIVE_NAME = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
REPO_ARCHIVE = (f'repo::{DEFAULT_ARCHIVE_NAME}',)
def test_make_base_create_produces_borg_command():
def test_make_base_create_command_checks_root_patterns_exist_and_produces_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist').once()
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -386,7 +387,39 @@ def test_make_base_create_produces_borg_command():
assert not pattern_file
def test_make_base_create_command_without_check_all_root_patterns_exist_skips_check_and_produces_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist').never()
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
(f'repo::{DEFAULT_ARCHIVE_NAME}',),
)
flexmock(module).should_receive('validate_planned_backup_paths').and_return(())
(create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command(
dry_run=False,
repository_path='repo',
config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'source_directories_must_exist': False,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
global_arguments=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
)
assert create_flags == ('borg', 'create', '--log-json')
assert create_positional_arguments == REPO_ARCHIVE
assert not pattern_file
def test_make_base_create_command_includes_patterns_file_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
mock_pattern_file = flexmock(name='/tmp/patterns')
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
@@ -424,6 +457,7 @@ def test_make_base_create_command_includes_patterns_file_in_borg_command():
def test_make_base_create_command_with_store_config_false_omits_config_files():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -482,6 +516,7 @@ def test_make_base_create_command_with_store_config_false_omits_config_files():
('flags', True, False, ()),
('flags', False, True, ('--noflags',)),
('flags', False, False, ('--nobsdflags',)),
('files_changed', 'mtime', True, ('--files-changed', 'mtime')),
('files_cache', 'ctime,size', True, ('--files-cache', 'ctime,size')),
('umask', 740, True, ('--umask', '740')),
('lock_wait', 5, True, ('--lock-wait', '5')),
@@ -493,6 +528,7 @@ def test_make_base_create_command_includes_configuration_option_as_command_flag(
feature_available,
option_flags,
):
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -526,6 +562,7 @@ def test_make_base_create_command_includes_configuration_option_as_command_flag(
def test_make_base_create_command_with_progress_omits_log_json_from_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -560,6 +597,7 @@ def test_make_base_create_command_with_progress_omits_log_json_from_borg_command
def test_make_base_create_command_with_log_json_and_progress_includes_log_json_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -595,6 +633,7 @@ def test_make_base_create_command_with_log_json_and_progress_includes_log_json_i
def test_make_base_create_command_includes_dry_run_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -628,6 +667,7 @@ def test_make_base_create_command_includes_dry_run_in_borg_command():
def test_make_base_create_command_includes_comment_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -662,6 +702,7 @@ def test_make_base_create_command_includes_comment_in_borg_command():
def test_make_base_create_command_includes_local_path_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -695,6 +736,7 @@ def test_make_base_create_command_includes_local_path_in_borg_command():
def test_make_base_create_command_includes_remote_path_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -728,6 +770,7 @@ def test_make_base_create_command_includes_remote_path_in_borg_command():
def test_make_base_create_command_includes_list_flags_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -763,6 +806,7 @@ def test_make_base_create_command_includes_list_flags_in_borg_command():
def test_make_base_create_command_with_stream_processes_ignores_read_special_false_and_excludes_special_files():
patterns = [Pattern('foo'), Pattern('bar')]
patterns_file = flexmock(name='patterns')
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').with_args(
patterns,
@@ -822,6 +866,7 @@ def test_make_base_create_command_with_stream_processes_ignores_read_special_fal
def test_make_base_create_command_without_patterns_and_with_stream_processes_ignores_read_special_false_and_excludes_special_files():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').with_args(
[],
@@ -881,6 +926,7 @@ def test_make_base_create_command_without_patterns_and_with_stream_processes_ign
def test_make_base_create_command_with_stream_processes_and_read_special_true_skips_special_files_excludes():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -916,6 +962,7 @@ def test_make_base_create_command_with_stream_processes_and_read_special_true_sk
def test_make_base_create_command_includes_archive_name_format_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -949,6 +996,7 @@ def test_make_base_create_command_includes_archive_name_format_in_borg_command()
def test_make_base_create_command_includes_default_archive_name_format_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -982,6 +1030,7 @@ def test_make_base_create_command_includes_default_archive_name_format_in_borg_c
def test_make_base_create_command_includes_archive_name_format_with_placeholders_in_borg_command():
repository_archive_pattern = 'repo::Documents_{hostname}-{now}'
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
@@ -1015,6 +1064,7 @@ def test_make_base_create_command_includes_archive_name_format_with_placeholders
def test_make_base_create_command_includes_repository_and_archive_name_format_with_placeholders_in_borg_command():
repository_archive_pattern = '{fqdn}::Documents_{hostname}-{now}'
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
@@ -1047,6 +1097,7 @@ def test_make_base_create_command_includes_repository_and_archive_name_format_wi
def test_make_base_create_command_includes_archive_suffix_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -1076,6 +1127,7 @@ def test_make_base_create_command_includes_archive_suffix_in_borg_command():
def test_make_base_create_command_includes_extra_borg_options_in_borg_command():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -1116,6 +1168,7 @@ def test_make_base_create_command_includes_extra_borg_options_in_borg_command():
def test_make_base_create_command_with_unsafe_skip_path_validation_before_create_skips_validation():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
@@ -1145,6 +1198,7 @@ def test_make_base_create_command_with_unsafe_skip_path_validation_before_create
def test_make_base_create_command_without_unsafe_skip_path_validation_before_create_calls_validation():
flexmock(module.borgmatic.borg.pattern).should_receive('check_all_root_patterns_exist')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
+849
View File
@@ -0,0 +1,849 @@
import logging
from flexmock import flexmock
from borgmatic.borg import diff as module
LOGGING_ANSWER = flexmock()
def test_diff_calls_borg_with_archives():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_local_path_calls_borg_with_it():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg6',
'diff',
'--log-json',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg6',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg6',
remote_path=None,
patterns=[],
)
def test_diff_with_remote_path_calls_borg_with_it():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--remote-path',
'borg7',
'--log-json',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path='borg7',
patterns=[],
)
def test_diff_with_lock_wait_calls_borg_with_it():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--lock-wait',
'5',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={'lock_wait': 5},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_log_level_info_calls_borg_with_info_flag():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.INFO)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--info',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_log_level_debug_calls_borg_with_debug_flags():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.logger).should_receive('isEnabledFor').and_return(logging.DEBUG)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--debug',
'--show-rc',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_only_patterns_calls_borg_with_configured_pattern_paths():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
flexmock(module).should_receive('write_patterns_file').and_return(
flexmock(name='/tmp/test_patterns')
).once()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--patterns-from',
'/tmp/test_patterns',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=True,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_exclude_config_calls_borg_with_exclude_flags():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(
('--exclude', 'stuff')
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--exclude',
'stuff',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_numeric_ids_calls_borg_with_numeric_ids_flag():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--numeric-ids',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={'numeric_ids': True},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_numeric_ids_and_feature_not_available_calls_borg_with_numeric_owner_flag():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').with_args(
module.borgmatic.borg.feature.Feature.NUMERIC_IDS, object
).and_return(False)
flexmock(module.borgmatic.borg.feature).should_receive('available').with_args(
module.borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, object
).and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--numeric-owner',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={'numeric_ids': True},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_same_chunker_params_calls_borg_with_it():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--same-chunker-params',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=True,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_sort_keys_calls_borg_with_formatted_sort_by_flags():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--sort-by',
'foo,bar,baz',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=['foo', 'bar', 'baz'],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_content_only_calls_borg_with_it():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--content-only',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=True,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_with_extra_borg_options_calls_borg_with_them():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('--repo', 'repo')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'--extra',
'--option',
'--repo',
'repo',
'archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={'extra_borg_options': {'diff': '--extra --option'}},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
def test_diff_without_separate_repository_archive_feature_available_calls_borg_joined_repository_archive():
flexmock(module.logging).ANSWER = LOGGING_ANSWER
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(
flexmock(name='test')
)
flexmock(module.borgmatic.borg.feature).should_receive('available').with_args(
module.borgmatic.borg.feature.Feature.NUMERIC_IDS, object
).and_return(True)
flexmock(module.borgmatic.borg.feature).should_receive('available').with_args(
module.borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, object
).and_return(False)
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').never()
flexmock(module.borgmatic.borg.flags).should_receive(
'make_repository_archive_flags'
).and_return(('repo::archive',))
environment = flexmock()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
full_command=(
'borg',
'diff',
'--log-json',
'repo::archive',
'archive2',
),
output_log_level=LOGGING_ANSWER,
environment=environment,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.borgmatic.borg.diff.diff(
repository='repo',
archive='archive',
second_archive='archive2',
config={},
local_borg_version=None,
diff_arguments=flexmock(
same_chunker_params=False,
sort_keys=[],
content_only=False,
second_archive='archive2',
only_patterns=False,
),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
patterns=[],
)
+2 -5
View File
@@ -90,11 +90,6 @@ def test_make_environment_without_configuration_sets_certain_environment_variabl
assert environment == {
'USER': 'root',
'BORG_EXIT_CODES': 'modern',
'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'NO',
'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'NO',
'BORG_USE_CHUNKS_ARCHIVE': 'NO',
'BORG_DEBUG_PASSPHRASE': 'NO',
'BORG_DISPLAY_PASSPHRASE': 'NO',
}
@@ -107,6 +102,7 @@ def test_make_environment_without_configuration_passes_through_default_environme
'BORG_USE_CHUNKS_ARCHIVE': 'yup',
'BORG_DEBUG_PASSPHRASE': 'nah',
'BORG_DISPLAY_PASSPHRASE': 'yup',
'BORG_MSGPACK_VERSION_CHECK': 'yup',
},
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
@@ -122,6 +118,7 @@ def test_make_environment_without_configuration_passes_through_default_environme
'BORG_USE_CHUNKS_ARCHIVE': 'yup',
'BORG_DEBUG_PASSPHRASE': 'nah',
'BORG_DISPLAY_PASSPHRASE': 'yup',
'BORG_MSGPACK_VERSION_CHECK': 'yup',
'BORG_EXIT_CODES': 'modern',
}
+15 -18
View File
@@ -246,10 +246,10 @@ def test_make_match_archives_flags_accepts_default_archive_name_format():
)
def test_warn_for_aggressive_archive_flags_without_archive_flags_bails():
def test_warn_for_aggressive_archive_flags_without_archive_flags_does_not_warn():
flexmock(module.logger).should_receive('warning').never()
module.warn_for_aggressive_archive_flags(('borg', '--do-stuff'), '{}')
module.warn_for_aggressive_archive_flags(('borg', '--do-stuff'), ())
def test_warn_for_aggressive_archive_flags_with_glob_archives_and_zero_archives_warns():
@@ -257,7 +257,7 @@ def test_warn_for_aggressive_archive_flags_with_glob_archives_and_zero_archives_
module.warn_for_aggressive_archive_flags(
('borg', '--glob-archives', 'foo*'),
'{"archives": []}',
(),
)
@@ -266,7 +266,16 @@ def test_warn_for_aggressive_archive_flags_with_match_archives_and_zero_archives
module.warn_for_aggressive_archive_flags(
('borg', '--match-archives', 'foo*'),
'{"archives": []}',
(),
)
def test_warn_for_aggressive_archive_flags_with_match_archives_and_just_exit_code_warns():
flexmock(module.logger).should_receive('warning').twice()
module.warn_for_aggressive_archive_flags(
('borg', '--match-archives', 'foo*'),
('terminating with success status, rc 0',),
)
@@ -275,7 +284,7 @@ def test_warn_for_aggressive_archive_flags_with_glob_archives_and_one_archive_do
module.warn_for_aggressive_archive_flags(
('borg', '--glob-archives', 'foo*'),
'{"archives": [{"name": "foo"]}',
('this is an archive line',),
)
@@ -284,22 +293,10 @@ def test_warn_for_aggressive_archive_flags_with_match_archives_and_one_archive_d
module.warn_for_aggressive_archive_flags(
('borg', '--match-archives', 'foo*'),
'{"archives": [{"name": "foo"]}',
('this is an archive line',),
)
def test_warn_for_aggressive_archive_flags_with_glob_archives_and_invalid_json_does_not_warn():
flexmock(module.logger).should_receive('warning').never()
module.warn_for_aggressive_archive_flags(('borg', '--glob-archives', 'foo*'), '{"archives": [}')
def test_warn_for_aggressive_archive_flags_with_glob_archives_and_json_missing_archives_does_not_warn():
flexmock(module.logger).should_receive('warning').never()
module.warn_for_aggressive_archive_flags(('borg', '--glob-archives', 'foo*'), '{}')
def test_omit_flag_removes_flag_from_arguments():
assert module.omit_flag(('borg', 'create', '--flag', '--other'), '--flag') == (
'borg',
+4 -13
View File
@@ -565,15 +565,14 @@ def test_make_info_command_with_date_based_matching_passes_through_to_command():
)
def test_display_archives_info_calls_two_commands():
def test_display_archives_info_calls_borg_command():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module).should_receive('make_info_command')
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').and_yield().once()
flexmock(module).should_receive('execute_command_and_capture_output').and_yield('').once()
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').once()
module.display_archives_info(
repository_path='repo',
@@ -592,7 +591,6 @@ def test_display_archives_info_with_json_calls_json_command_only():
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').and_yield('{}')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
flexmock(module).should_receive('execute_command').never()
assert (
module.display_archives_info(
@@ -616,20 +614,13 @@ def test_display_archives_info_calls_borg_with_working_directory():
)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
full_command=object,
output_log_level=int,
environment=object,
working_directory='/working/dir',
borg_local_path=object,
borg_exit_codes=object,
).and_yield().once()
).and_yield('').once()
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
full_command=object,
output_log_level=object,
environment=object,
working_directory='/working/dir',
borg_local_path=object,
borg_exit_codes=object,
).once()
module.display_archives_info(
repository_path='repo',
+49 -12
View File
@@ -115,6 +115,7 @@ def test_get_latest_archive_calls_borg_with_flags():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -144,6 +145,7 @@ def test_get_latest_archive_with_log_info_calls_borg_without_info_flag():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -174,6 +176,7 @@ def test_get_latest_archive_with_log_debug_calls_borg_without_debug_flag():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -204,6 +207,7 @@ def test_get_latest_archive_with_local_path_calls_borg_via_local_path():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -234,6 +238,7 @@ def test_get_latest_archive_with_exit_codes_calls_borg_using_them():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -267,6 +272,7 @@ def test_get_latest_archive_with_remote_path_calls_borg_with_remote_path_flags()
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -300,6 +306,7 @@ def test_get_latest_archive_with_umask_calls_borg_with_umask_flags():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -329,6 +336,7 @@ def test_get_latest_archive_without_archives_raises():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
@@ -358,6 +366,7 @@ def test_get_latest_archive_with_lock_wait_calls_borg_with_lock_wait_flags():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -380,6 +389,38 @@ def test_get_latest_archive_with_lock_wait_calls_borg_with_lock_wait_flags():
)
def test_get_latest_archive_with_match_archives_calls_borg_with_match_archives_flags():
expected_archive = {'name': 'archive-name', 'id': 'd34db33f'}
flexmock(module.feature).should_receive('available').and_return(False)
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(
('--match-archives', 'foo')
)
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
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', 'list', '--log-json', '--match-archives', 'foo', *BORG_LIST_LATEST_ARGUMENTS),
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_yield(json.dumps({'archives': [expected_archive]}))
assert (
module.get_latest_archive(
'repo',
config={'match_archives': 'foo'},
local_borg_version='1.2.3',
global_arguments=flexmock(),
)
== expected_archive
)
def test_get_latest_archive_calls_borg_with_list_extra_borg_options():
expected_archive = {'name': 'archive-name', 'id': 'd34db33f'}
flexmock(module.feature).should_receive('available').and_return(False)
@@ -387,6 +428,7 @@ def test_get_latest_archive_calls_borg_with_list_extra_borg_options():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -424,6 +466,7 @@ def test_get_latest_archive_with_feature_available_calls_borg_with_repo_list_ext
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -464,6 +507,7 @@ def test_get_latest_archive_with_consider_checkpoints_calls_borg_with_consider_c
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -497,6 +541,7 @@ def test_get_latest_archive_with_consider_checkpoints_and_feature_available_call
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -527,6 +572,7 @@ def test_get_latest_archive_calls_borg_with_working_directory():
flexmock(module.flags).should_receive('make_flags').with_args('last', 1).and_return(
('--last', '1')
)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
@@ -1108,7 +1154,7 @@ def test_make_repo_list_command_with_match_archives_calls_borg_with_match_archiv
assert command == ('borg', 'list', '--log-json', '--match-archives', 'foo-*', 'repo')
def test_list_repository_calls_two_commands():
def test_list_repository_calls_borg_command():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module).should_receive('make_repo_list_command')
@@ -1116,7 +1162,6 @@ def test_list_repository_calls_two_commands():
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').and_yield('').once()
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').once()
module.list_repository(
repository_path='repo',
@@ -1127,14 +1172,13 @@ def test_list_repository_calls_two_commands():
)
def test_list_repository_with_json_calls_json_command_only():
def test_list_repository_with_json_calls_borg_json_command_only():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module).should_receive('make_repo_list_command')
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').and_yield('{}')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
flexmock(module).should_receive('execute_command').never()
assert (
module.list_repository(
@@ -1206,20 +1250,13 @@ def test_list_repository_calls_borg_with_working_directory():
)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
full_command=object,
output_log_level=int,
environment=object,
working_directory='/working/dir',
borg_local_path=object,
borg_exit_codes=object,
).and_yield('').once()
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
full_command=object,
output_log_level=object,
environment=object,
working_directory='/working/dir',
borg_local_path=object,
borg_exit_codes=object,
).once()
module.list_repository(
repository_path='repo',
+54 -36
View File
@@ -329,10 +329,12 @@ def test_parse_arguments_for_actions_consumes_action_arguments_after_action_name
remaining = flexmock()
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace},
)
or remaining,
lambda unparsed, parsed, parser, action, canonical=None: (
parsed.update(
{action: action_namespace},
)
or remaining
),
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {'action': flexmock(), 'other': flexmock()}
@@ -355,10 +357,12 @@ def test_parse_arguments_for_actions_consumes_action_arguments_with_alias():
remaining = flexmock()
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{canonical or action: action_namespace},
)
or remaining,
lambda unparsed, parsed, parser, action, canonical=None: (
parsed.update(
{canonical or action: action_namespace},
)
or remaining
),
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {
@@ -387,10 +391,12 @@ def test_parse_arguments_for_actions_consumes_multiple_action_arguments():
other_namespace = flexmock(bar=3)
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace if action == 'action' else other_namespace},
)
or (),
lambda unparsed, parsed, parser, action, canonical=None: (
parsed.update(
{action: action_namespace if action == 'action' else other_namespace},
)
or ()
),
).and_return(('other', '--bar', '3')).and_return('action', '--foo', 'true')
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {
@@ -420,10 +426,12 @@ def test_parse_arguments_for_actions_respects_command_line_action_ordering():
action_namespace = flexmock(foo=True)
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: other_namespace if action == 'other' else action_namespace},
)
or (),
lambda unparsed, parsed, parser, action, canonical=None: (
parsed.update(
{action: other_namespace if action == 'other' else action_namespace},
)
or ()
),
).and_return(('action',)).and_return(('other', '--foo', 'true'))
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {
@@ -458,10 +466,12 @@ def test_parse_arguments_for_actions_applies_default_action_parsers():
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: namespaces.get(action)},
)
or (),
lambda unparsed, parsed, parser, action, canonical=None: (
parsed.update(
{action: namespaces.get(action)},
)
or ()
),
).and_return(())
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {
@@ -488,10 +498,12 @@ def test_parse_arguments_for_actions_consumes_global_arguments():
action_namespace = flexmock()
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace},
)
or ('--verbosity', 'lots'),
lambda unparsed, parsed, parser, action, canonical=None: (
parsed.update(
{action: action_namespace},
)
or ('--verbosity', 'lots')
),
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {
@@ -516,10 +528,12 @@ def test_parse_arguments_for_actions_passes_through_unknown_arguments_before_act
action_namespace = flexmock()
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace},
)
or ('--wtf', 'yes'),
lambda unparsed, parsed, parser, action, canonical=None: (
parsed.update(
{action: action_namespace},
)
or ('--wtf', 'yes')
),
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {
@@ -544,10 +558,12 @@ def test_parse_arguments_for_actions_passes_through_unknown_arguments_after_acti
action_namespace = flexmock()
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace},
)
or ('--wtf', 'yes'),
lambda unparsed, parsed, parser, action, canonical=None: (
parsed.update(
{action: action_namespace},
)
or ('--wtf', 'yes')
),
)
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {
@@ -572,10 +588,12 @@ def test_parse_arguments_for_actions_with_borg_action_skips_other_action_parsers
action_namespace = flexmock(options=[])
flexmock(module).should_receive('get_subaction_parsers').and_return({})
flexmock(module).should_receive('parse_and_record_action_arguments').replace_with(
lambda unparsed, parsed, parser, action, canonical=None: parsed.update(
{action: action_namespace},
)
or (),
lambda unparsed, parsed, parser, action, canonical=None: (
parsed.update(
{action: action_namespace},
)
or ()
),
).and_return(())
flexmock(module).should_receive('get_subactions_for_actions').and_return({})
action_parsers = {
+85 -7
View File
@@ -2058,6 +2058,43 @@ def test_collect_highlander_action_summary_logs_error_on_run_validate_failure():
assert {log.levelno for log in logs} == {logging.CRITICAL}
def test_collect_highlander_action_summary_logs_nothing_additional_for_success_with_show():
flexmock(module.borgmatic.actions.config.show).should_receive('run_show')
arguments = {
'show': flexmock(),
'global': flexmock(),
}
logs = tuple(
module.collect_highlander_action_summary_logs(
{'test.yaml': {}},
arguments=arguments,
configuration_parse_errors=False,
),
)
assert not logs
def test_collect_highlander_action_summary_logs_error_on_run_show_failure():
flexmock(module.borgmatic.actions.config.show).should_receive('run_show').and_raise(
ValueError,
)
arguments = {
'show': flexmock(),
'global': flexmock(),
}
logs = tuple(
module.collect_highlander_action_summary_logs(
{'test.yaml': {}},
arguments=arguments,
configuration_parse_errors=False,
),
)
assert {log.levelno for log in logs} == {logging.CRITICAL}
def test_collect_configuration_run_summary_logs_info_for_success():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks').with_args(
@@ -2545,23 +2582,40 @@ def test_collect_configuration_run_summary_logs_outputs_merged_json_results():
def test_check_and_show_help_on_no_args_shows_help_when_no_args_and_default_actions_false():
flexmock(module.sys).should_receive('argv').and_return(['borgmatic'])
flexmock(module).should_receive('parse_arguments').with_args('--help').once()
flexmock(module).should_receive('parse_arguments').with_args({}, '--help').once()
flexmock(module.sys).should_receive('exit').with_args(0).once()
module.check_and_show_help_on_no_args({'test.yaml': {'default_actions': False}})
module.check_and_show_help_on_no_args(
configs={'test.yaml': {'default_actions': False}}, schema={}
)
def test_check_and_show_help_on_no_args_does_not_show_help_when_no_args_and_no_configurations():
flexmock(module.sys).should_receive('argv').and_return(['borgmatic'])
flexmock(module).should_receive('parse_arguments').never()
flexmock(module.sys).should_receive('exit').never()
module.check_and_show_help_on_no_args(configs={}, schema={})
def test_check_and_show_help_on_no_args_does_not_show_help_when_no_args_and_default_actions_true():
flexmock(module.sys).should_receive('argv').and_return(['borgmatic'])
flexmock(module).should_receive('parse_arguments').never()
flexmock(module.sys).should_receive('exit').never()
module.check_and_show_help_on_no_args({'test.yaml': {'default_actions': True}})
module.check_and_show_help_on_no_args(
configs={'test.yaml': {'default_actions': True}}, schema={}
)
def test_check_and_show_help_on_no_args_does_not_show_help_when_args_provided():
flexmock(module.sys).should_receive('argv').and_return(['borgmatic', '--create'])
flexmock(module).should_receive('parse_arguments').never()
flexmock(module.sys).should_receive('exit').never()
module.check_and_show_help_on_no_args({'test.yaml': {'default_actions': False}})
module.check_and_show_help_on_no_args(
configs={'test.yaml': {'default_actions': False}}, schema={}
)
def test_check_and_show_help_on_no_args_with_no_default_actions_in_all_configs():
@@ -2574,10 +2628,10 @@ def test_check_and_show_help_on_no_args_with_no_default_actions_in_all_configs()
}
# Expect help to be shown
flexmock(module).should_receive('parse_arguments').with_args('--help').once()
flexmock(module).should_receive('parse_arguments').with_args({}, '--help').once()
flexmock(module.sys).should_receive('exit').with_args(0).once()
module.check_and_show_help_on_no_args(configs)
module.check_and_show_help_on_no_args(configs=configs, schema={})
def test_check_and_show_help_on_no_args_with_conflicting_configs():
@@ -2593,7 +2647,7 @@ def test_check_and_show_help_on_no_args_with_conflicting_configs():
flexmock(module).should_receive('parse_arguments').never()
flexmock(module.sys).should_receive('exit').never()
module.check_and_show_help_on_no_args(configs)
module.check_and_show_help_on_no_args(configs=configs, schema={})
def test_get_singular_option_value_with_conflicting_values_exits():
@@ -2652,3 +2706,27 @@ def test_get_singular_option_value_with_no_config_returns_none():
)
is None
)
def test_run_actions_runs_diff():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
flexmock(),
)
flexmock(module.command).should_receive('Before_after_hooks').and_return(flexmock())
flexmock(borgmatic.actions.diff).should_receive('run_diff').once()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False), 'diff': flexmock()},
config_filename=flexmock(),
config={'repositories': []},
config_paths=[],
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
),
)
-15
View File
@@ -47,11 +47,6 @@ from borgmatic.config import normalize as module
{'prefix': 'foo'},
True,
),
(
{'location': {'prefix': 'foo'}, 'consistency': {'prefix': 'foo'}},
{'prefix': 'foo'},
True,
),
(
{'location': {'prefix': 'foo'}, 'consistency': {'bar': 'baz'}},
{'prefix': 'foo', 'bar': 'baz'},
@@ -62,11 +57,6 @@ from borgmatic.config import normalize as module
{'umask': 'foo'},
True,
),
(
{'storage': {'umask': 'foo'}, 'hooks': {'umask': 'foo'}},
{'umask': 'foo'},
True,
),
(
{'storage': {'umask': 'foo'}, 'hooks': {'bar': 'baz'}},
{'umask': 'foo', 'bar': 'baz'},
@@ -288,11 +278,6 @@ def test_normalize_commands_moves_individual_command_hooks_to_unified_commands(
{'checks': [{'name': 'archives'}]},
True,
),
(
{'checks': ['archives']},
{'checks': [{'name': 'archives'}]},
True,
),
(
{'numeric_owner': False},
{'numeric_ids': False},
+53 -3
View File
@@ -1,13 +1,55 @@
import sys
import pytest
from flexmock import flexmock
from borgmatic.hooks.data_source import bootstrap as module
def test_dump_data_sources_creates_manifest_file():
flexmock(module.os).should_receive('makedirs')
def test_resolve_config_path_symlinks_passes_through_non_symlink():
flexmock(module.os.path).should_receive('abspath').replace_with(lambda path: path)
flexmock(module.os.path).should_receive('islink').and_return(False)
assert tuple(module.resolve_config_path_symlinks('test.yaml')) == ('test.yaml',)
def test_resolve_config_path_symlinks_follows_each_symlink():
flexmock(module.os.path).should_receive('abspath').replace_with(lambda path: path)
flexmock(module.os.path).should_receive('islink').with_args('test.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest1.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest2.yaml').and_return(False)
flexmock(module.os).should_receive('readlink').with_args('test.yaml').and_return('dest1.yaml')
flexmock(module.os).should_receive('readlink').with_args('dest1.yaml').and_return('dest2.yaml')
flexmock(module.os).should_receive('readlink').with_args('dest2.yaml').never()
assert tuple(module.resolve_config_path_symlinks('test.yaml')) == (
'test.yaml',
'dest1.yaml',
'dest2.yaml',
)
def test_resolve_config_path_symlinks_with_too_many_symlinks_raises():
flexmock(module).MAXIMUM_CONFIG_SYMLINKS_TO_FOLLOW = 2
flexmock(module.os.path).should_receive('abspath').replace_with(lambda path: path)
flexmock(module.os.path).should_receive('islink').with_args('test.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest1.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest2.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest3.yaml').never()
flexmock(module.os).should_receive('readlink').with_args('test.yaml').and_return('dest1.yaml')
flexmock(module.os).should_receive('readlink').with_args('dest1.yaml').and_return('dest2.yaml')
flexmock(module.os).should_receive('readlink').with_args('dest2.yaml').and_return('dest3.yaml')
flexmock(module.os).should_receive('readlink').with_args('dest3.yaml').never()
with pytest.raises(ValueError):
assert tuple(module.resolve_config_path_symlinks('test.yaml'))
def test_dump_data_sources_creates_manifest_file():
flexmock(module).should_receive('resolve_config_path_symlinks').and_yield(
'test.yaml', 'linkdest.yaml'
)
flexmock(module.os).should_receive('makedirs')
flexmock(module.importlib.metadata).should_receive('version').and_return('1.0.0')
manifest_file = flexmock(
__enter__=lambda *args: flexmock(write=lambda *args: None, close=lambda *args: None),
@@ -19,7 +61,7 @@ def test_dump_data_sources_creates_manifest_file():
encoding='utf-8',
).and_return(manifest_file)
flexmock(module.json).should_receive('dump').with_args(
{'borgmatic_version': '1.0.0', 'config_paths': ('test.yaml',)},
{'borgmatic_version': '1.0.0', 'config_paths': ('test.yaml', 'linkdest.yaml')},
manifest_file,
).once()
flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
@@ -34,6 +76,12 @@ def test_dump_data_sources_creates_manifest_file():
'test.yaml', source=module.borgmatic.borg.pattern.Pattern_source.HOOK
),
).once()
flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
object,
module.borgmatic.borg.pattern.Pattern(
'linkdest.yaml', source=module.borgmatic.borg.pattern.Pattern_source.HOOK
),
).once()
module.dump_data_sources(
hook_config=None,
@@ -46,6 +94,7 @@ def test_dump_data_sources_creates_manifest_file():
def test_dump_data_sources_with_store_config_files_false_does_not_create_manifest_file():
flexmock(module).should_receive('resolve_config_path_symlinks').and_yield('test.yaml')
flexmock(module.os).should_receive('makedirs').never()
flexmock(module.json).should_receive('dump').never()
flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').never()
@@ -62,6 +111,7 @@ def test_dump_data_sources_with_store_config_files_false_does_not_create_manifes
def test_dump_data_sources_with_dry_run_does_not_create_manifest_file():
flexmock(module).should_receive('resolve_config_path_symlinks').and_yield('test.yaml')
flexmock(module.os).should_receive('makedirs').never()
flexmock(module.json).should_receive('dump').never()
flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').never()
+5 -10
View File
@@ -50,10 +50,7 @@ def test_loki_log_buffer_json_serializes_log_lines():
assert json.loads(buffer.to_request())['streams'][0]['values'][0][1] == 'Some test log line'
def test_loki_log_handler_add_label_gets_labels():
'''
Assert that adding labels works.
'''
def test_loki_log_buffer_add_label_gets_labels():
buffer = module.Loki_log_buffer(flexmock(), dry_run=False)
buffer.add_label('test', 'label')
@@ -64,22 +61,20 @@ def test_loki_log_handler_add_label_gets_labels():
def test_loki_log_handler_emit_with_send_logs_records_log_message():
handler = module.Loki_log_handler(flexmock(), send_logs=True, dry_run=False)
handler = module.Loki_log_handler(flexmock(), send_logs=True, log_level=10, dry_run=False)
flexmock(handler).should_receive('raw').once()
handler.emit(flexmock(getMessage=lambda: 'Some test log line'))
def test_loki_log_handler_emit_without_send_logs_skips_log_message():
handler = module.Loki_log_handler(flexmock(), send_logs=False, dry_run=False)
handler = module.Loki_log_handler(flexmock(), send_logs=False, log_level=10, dry_run=False)
flexmock(handler).should_receive('raw').never()
handler.emit(flexmock(getMessage=lambda: 'Some test log line'))
def test_loki_log_handler_flush_with_empty_buffer_does_not_raise():
'''
Test that flushing an empty buffer does indeed nothing.
'''
handler = module.Loki_log_handler(flexmock(), send_logs=False, dry_run=False)
handler = module.Loki_log_handler(flexmock(), send_logs=False, log_level=10, dry_run=False)
handler.flush()
+61 -2
View File
@@ -1,10 +1,26 @@
from enum import Enum
import pytest
from flexmock import flexmock
import borgmatic.hooks.monitoring.monitor
from borgmatic.hooks.monitoring import ntfy as module
@pytest.mark.parametrize(
'tags_input,expected_tags_output',
(
(None, []),
('', []),
('foo', ['foo']),
(' foo , bar ,baz ', ['foo', 'bar', 'baz']),
('foo,,bar,', ['foo', 'bar']),
),
)
def test_convert_string_to_array(tags_input, expected_tags_output):
assert module.convert_string_to_array(tags_input) == expected_tags_output
DEFAULT_BASE_URL = 'https://ntfy.sh'
CUSTOM_BASE_URL = 'https://ntfy.example.com'
TOPIC = 'borgmatic-unit-testing'
@@ -25,7 +41,7 @@ CUSTOM_MESSAGE_PAYLOAD = {
'title': CUSTOM_MESSAGE_CONFIG['title'],
'message': CUSTOM_MESSAGE_CONFIG['message'],
'priority': 1,
'tags': CUSTOM_MESSAGE_CONFIG['tags'],
'tags': [CUSTOM_MESSAGE_CONFIG['tags']],
}
@@ -35,12 +51,15 @@ def default_message_payload(state=Enum):
'title': f'A borgmatic {state.name} event happened',
'message': f'A borgmatic {state.name} event happened',
'priority': 3,
'tags': 'borgmatic',
'tags': ['borgmatic'],
}
def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -67,6 +86,9 @@ def test_ping_monitor_with_access_token_hits_hosted_ntfy_on_fail():
'topic': TOPIC,
'access_token': 'abc123',
}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -95,6 +117,9 @@ def test_ping_monitor_with_username_password_and_access_token_ignores_username_p
'password': 'fakepassword',
'access_token': 'abc123',
}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -123,6 +148,9 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
'username': 'testuser',
'password': 'fakepassword',
}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -146,6 +174,9 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
def test_ping_monitor_with_password_but_no_username_warns():
hook_config = {'topic': TOPIC, 'password': 'fakepassword'}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -170,6 +201,9 @@ def test_ping_monitor_with_password_but_no_username_warns():
def test_ping_monitor_with_username_but_no_password_warns():
hook_config = {'topic': TOPIC, 'username': 'testuser'}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -194,6 +228,9 @@ def test_ping_monitor_with_username_but_no_password_warns():
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -211,6 +248,9 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -228,6 +268,9 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
hook_config = {'topic': TOPIC, 'server': CUSTOM_BASE_URL}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -251,6 +294,9 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -268,6 +314,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
hook_config = {'topic': TOPIC, 'fail': CUSTOM_MESSAGE_CONFIG}
flexmock(module).should_receive('convert_string_to_array').with_args('+1').and_return(['+1'])
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -291,6 +338,9 @@ def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
hook_config = {'topic': TOPIC, 'states': ['start', 'fail']}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -314,6 +364,9 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
def test_ping_monitor_with_connection_error_logs_warning():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -338,6 +391,9 @@ def test_ping_monitor_with_connection_error_logs_warning():
def test_ping_monitor_with_credential_error_logs_warning():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).and_raise(ValueError)
@@ -356,6 +412,9 @@ def test_ping_monitor_with_credential_error_logs_warning():
def test_ping_monitor_with_other_error_logs_warning():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
+254 -79
View File
@@ -7,72 +7,78 @@ from borgmatic import execute as module
@pytest.mark.parametrize(
'command,exit_code,borg_local_path,borg_exit_codes,expected_result',
'command,borg_local_path,expected_result',
(
(['grep'], 2, None, None, module.Exit_status.ERROR),
(['grep'], 2, 'borg', None, module.Exit_status.ERROR),
(['borg'], 2, 'borg', None, module.Exit_status.ERROR),
(['borg1'], 2, 'borg1', None, module.Exit_status.ERROR),
(['grep'], 1, None, None, module.Exit_status.ERROR),
(['grep'], 1, 'borg', None, module.Exit_status.ERROR),
(['borg'], 1, 'borg', None, module.Exit_status.WARNING),
(['borg1'], 1, 'borg1', None, module.Exit_status.WARNING),
(['grep'], 100, None, None, module.Exit_status.ERROR),
(['grep'], 100, 'borg', None, module.Exit_status.ERROR),
('grep', 2, None, None, module.Exit_status.ERROR),
('borg', 2, 'borg', None, module.Exit_status.ERROR),
(['borg'], 100, 'borg', None, module.Exit_status.WARNING),
(['borg1'], 100, 'borg1', None, module.Exit_status.WARNING),
('borg', 100, 'borg', None, module.Exit_status.WARNING),
('borg1', 100, 'borg1', None, module.Exit_status.WARNING),
(['grep'], 0, None, None, module.Exit_status.SUCCESS),
(['grep'], 0, 'borg', None, module.Exit_status.SUCCESS),
(['borg'], 0, 'borg', None, module.Exit_status.SUCCESS),
(['borg1'], 0, 'borg1', None, module.Exit_status.SUCCESS),
('grep', 0, None, None, module.Exit_status.SUCCESS),
('grep', 0, 'borg', None, module.Exit_status.SUCCESS),
(['foo', 'bar'], 'borg', False),
(['borg', 'list'], 'borg', True),
(['borg1', 'list'], 'borg', False),
(['borg', 'list'], 'borg1', False),
([], 'borg', False),
('foo bar', 'borg', False),
('borg list', 'borg', True),
('', 'borg', False),
),
)
def test_command_is_borg_matches_local_path_to_command(command, borg_local_path, expected_result):
assert module.command_is_borg(command, borg_local_path) == expected_result
@pytest.mark.parametrize(
'command_is_borg,exit_code,borg_exit_codes,expected_result',
(
(False, 2, None, module.Exit_status.ERROR),
(True, 2, None, module.Exit_status.ERROR),
(False, 1, None, module.Exit_status.ERROR),
(True, 1, None, module.Exit_status.WARNING),
(False, 100, None, module.Exit_status.ERROR),
(True, 100, None, module.Exit_status.WARNING),
(False, 0, None, module.Exit_status.SUCCESS),
(True, 0, None, module.Exit_status.SUCCESS),
# -9 exit code occurs when child process get SIGKILLed.
(['grep'], -9, None, None, module.Exit_status.ERROR),
(['grep'], -9, 'borg', None, module.Exit_status.ERROR),
(['borg'], -9, 'borg', None, module.Exit_status.ERROR),
(['borg1'], -9, 'borg1', None, module.Exit_status.ERROR),
(['borg'], None, None, None, module.Exit_status.STILL_RUNNING),
(['borg'], 1, 'borg', [], module.Exit_status.WARNING),
(['borg'], 1, 'borg', [{}], module.Exit_status.WARNING),
(['borg'], 1, 'borg', [{'code': 1}], module.Exit_status.WARNING),
(['grep'], 1, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
(['borg'], 1, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.WARNING),
(['borg'], 1, 'borg', [{'code': 1, 'treat_as': 'error'}], module.Exit_status.ERROR),
(['borg'], 2, 'borg', [{'code': 99, 'treat_as': 'warning'}], module.Exit_status.ERROR),
(['borg'], 2, 'borg', [{'code': 2, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 100, 'borg', [{'code': 1, 'treat_as': 'error'}], module.Exit_status.WARNING),
(['borg'], 100, 'borg', [], module.Exit_status.WARNING),
(['borg'], 100, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
(['borg'], 101, 'borg', [], module.Exit_status.ERROR),
(['borg'], 101, 'borg', [{'code': 101, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 102, 'borg', [], module.Exit_status.ERROR),
(['borg'], 102, 'borg', [{'code': 102, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 103, 'borg', [], module.Exit_status.WARNING),
(['borg'], 103, 'borg', [{'code': 103, 'treat_as': 'error'}], module.Exit_status.ERROR),
(['borg'], 104, 'borg', [], module.Exit_status.ERROR),
(['borg'], 104, 'borg', [{'code': 104, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 105, 'borg', [], module.Exit_status.ERROR),
(['borg'], 105, 'borg', [{'code': 105, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 106, 'borg', [], module.Exit_status.ERROR),
(['borg'], 106, 'borg', [{'code': 106, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 107, 'borg', [], module.Exit_status.ERROR),
(['borg'], 107, 'borg', [{'code': 107, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(False, -9, None, module.Exit_status.ERROR),
(True, -9, None, module.Exit_status.ERROR),
(True, None, None, module.Exit_status.STILL_RUNNING),
(True, 1, [], module.Exit_status.WARNING),
(True, 1, [{'code': 1}], module.Exit_status.WARNING),
(False, 1, [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
(True, 1, [{'code': 100, 'treat_as': 'error'}], module.Exit_status.WARNING),
(True, 1, [{'code': 1, 'treat_as': 'error'}], module.Exit_status.ERROR),
(True, 2, [{'code': 99, 'treat_as': 'warning'}], module.Exit_status.ERROR),
(True, 2, [{'code': 2, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 100, [{'code': 1, 'treat_as': 'error'}], module.Exit_status.WARNING),
(True, 100, [], module.Exit_status.WARNING),
(True, 100, [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
(True, 101, [], module.Exit_status.ERROR),
(True, 101, [{'code': 101, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 102, [], module.Exit_status.ERROR),
(True, 102, [{'code': 102, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 103, [], module.Exit_status.WARNING),
(True, 103, [{'code': 103, 'treat_as': 'error'}], module.Exit_status.ERROR),
(True, 104, [], module.Exit_status.ERROR),
(True, 104, [{'code': 104, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 105, [], module.Exit_status.ERROR),
(True, 105, [{'code': 105, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 106, [], module.Exit_status.ERROR),
(True, 106, [{'code': 106, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 107, [], module.Exit_status.WARNING),
(True, 107, [{'code': 107, 'treat_as': 'error'}], module.Exit_status.ERROR),
),
)
def test_interpret_exit_code_respects_exit_code_and_borg_local_path(
command,
command_is_borg,
exit_code,
borg_local_path,
borg_exit_codes,
expected_result,
):
flexmock(module).should_receive('command_is_borg').and_return(command_is_borg)
assert (
module.interpret_exit_code(command, exit_code, borg_local_path, borg_exit_codes)
module.interpret_exit_code(
command=flexmock(),
exit_code=exit_code,
borg_local_path=flexmock(),
borg_exit_codes=borg_exit_codes,
)
is expected_result
)
@@ -122,6 +128,42 @@ def test_borg_json_log_line_to_record_parses_log_message_line():
assert record.name == 'borg.something'
def test_borg_json_log_line_to_record_elevates_log_message_info_level_to_small_jump_in_log_level():
line = '{"type": "log_message", "levelname": "INFO", "time": 12345, "message": "All done", "name": "borg.something"}'
record = module.borg_json_log_line_to_record(line, 25)
assert record.levelno == 25
assert record.created == 12345
assert record.msg == 'All done'
assert record.levelname in {'ANSWER', 'Level 25'}
assert record.name == 'borg.something'
def test_borg_json_log_line_to_record_does_not_elevate_log_message_info_level_to_big_jump_in_log_level():
line = '{"type": "log_message", "levelname": "INFO", "time": 12345, "message": "All done", "name": "borg.something"}'
record = module.borg_json_log_line_to_record(line, 40)
assert record.levelno == module.logging.INFO
assert record.created == 12345
assert record.msg == 'All done'
assert record.levelname == 'INFO'
assert record.name == 'borg.something'
def test_borg_json_log_line_with_none_log_level_parses_log_message_line():
line = '{"type": "log_message", "levelname": "INFO", "time": 12345, "message": "All done", "name": "borg.something"}'
record = module.borg_json_log_line_to_record(line, None)
assert record.levelno == module.logging.INFO
assert record.created == 12345
assert record.msg == 'All done'
assert record.levelname == 'INFO'
assert record.name == 'borg.something'
def test_borg_json_log_line_to_record_parses_file_status_line():
flexmock(module.time).should_receive('time').and_return(12345)
line = '{"type": "file_status", "status": "-", "path": "/foo/bar"}'
@@ -361,7 +403,7 @@ def test_log_buffer_lines_with_ready_buffer_and_running_process_handles_each_log
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -393,7 +435,7 @@ def test_log_buffer_lines_with_ready_buffer_and_capture_process_yields_each_line
) == ('message', 'message')
def test_log_buffer_lines_with_ready_buffer_and_log_level_and_capture_process_does_not_yield_each_line():
def test_log_buffer_lines_with_ready_buffer_and_same_log_level_and_capture_process_yields_each_line():
process = flexmock(poll=lambda: None, stderr=flexmock(), args=flexmock())
buffer_readers = {
flexmock(): module.Buffer_reader(lines=iter((('hi', 'there'),)), process=process)
@@ -404,7 +446,31 @@ def test_log_buffer_lines_with_ready_buffer_and_log_level_and_capture_process_do
).and_return(list(buffer_readers.keys()), [], [])
flexmock(module).should_receive('parse_log_line').and_return(flexmock())
flexmock(module).should_receive('handle_log_record').and_return(
flexmock(levelno=10, getMessage=lambda: 'message')
flexmock(levelno=module.logging.INFO, getMessage=lambda: 'message')
).twice()
assert tuple(
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
) == ('message', 'message')
def test_log_buffer_lines_with_ready_buffer_and_higher_log_level_and_capture_process_does_not_yield_each_line():
process = flexmock(poll=lambda: None, stderr=flexmock(), args=flexmock())
buffer_readers = {
flexmock(): module.Buffer_reader(lines=iter((('hi', 'there'),)), process=process)
}
process_metadatas = {process: module.Process_metadata(last_lines=[], capture=True)}
flexmock(module.select).should_receive('select').with_args(
buffer_readers.keys(), [], []
).and_return(list(buffer_readers.keys()), [], [])
flexmock(module).should_receive('parse_log_line').and_return(flexmock())
flexmock(module).should_receive('handle_log_record').and_return(
flexmock(levelno=module.logging.DEBUG, getMessage=lambda: 'message')
).twice()
assert (
@@ -412,7 +478,7 @@ def test_log_buffer_lines_with_ready_buffer_and_log_level_and_capture_process_do
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -420,6 +486,30 @@ def test_log_buffer_lines_with_ready_buffer_and_log_level_and_capture_process_do
)
def test_log_buffer_lines_with_ready_buffer_and_lower_log_level_and_capture_process_yields_each_line():
process = flexmock(poll=lambda: None, stderr=flexmock(), args=flexmock())
buffer_readers = {
flexmock(): module.Buffer_reader(lines=iter((('hi', 'there'),)), process=process)
}
process_metadatas = {process: module.Process_metadata(last_lines=[], capture=True)}
flexmock(module.select).should_receive('select').with_args(
buffer_readers.keys(), [], []
).and_return(list(buffer_readers.keys()), [], [])
flexmock(module).should_receive('parse_log_line').and_return(flexmock())
flexmock(module).should_receive('handle_log_record').and_return(
flexmock(levelno=module.logging.ERROR, getMessage=lambda: 'message')
).twice()
assert tuple(
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
) == ('message', 'message')
def test_log_buffer_lines_with_ready_buffer_and_finished_process_vents_other_processes():
process_stdout = flexmock()
process = flexmock(poll=lambda: 0, stdout=process_stdout, stderr=flexmock(), args=flexmock())
@@ -443,7 +533,7 @@ def test_log_buffer_lines_with_ready_buffer_and_finished_process_vents_other_pro
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -480,7 +570,7 @@ def test_log_buffer_lines_with_ready_buffer_and_finished_process_does_not_vent_o
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -506,7 +596,7 @@ def test_log_buffer_lines_with_ready_eof_buffer_and_running_process_skips_it():
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -529,7 +619,7 @@ def test_log_buffer_lines_with_ready_buffer_with_empty_line_skips_it():
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -559,7 +649,7 @@ def test_log_buffer_lines_with_multiple_ready_buffers_and_running_processes_hand
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -587,7 +677,7 @@ def test_log_buffer_lines_with_multiple_ready_buffers_from_same_running_process_
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -615,7 +705,7 @@ def test_log_buffer_lines_with_ready_stderr_buffer_and_running_process_elevates_
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -643,7 +733,7 @@ def test_log_buffer_lines_with_ready_stdout_buffer_and_running_process_does_not_
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -671,7 +761,7 @@ def test_log_buffer_lines_with_ready_stderr_buffer_and_capture_stderr_does_not_e
module.log_buffer_lines(
buffer_readers=buffer_readers,
process_metadatas=process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
capture_stderr=True,
)
@@ -951,7 +1041,7 @@ def test_log_remaining_buffer_lines_without_reader_process_bails():
module.log_remaining_buffer_lines(
buffer_readers,
process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -978,7 +1068,7 @@ def test_log_remaining_buffer_lines_logs_each_line():
module.log_remaining_buffer_lines(
buffer_readers,
process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -1009,7 +1099,7 @@ def test_log_remaining_buffer_lines_with_multiple_buffers_logs_lines_from_each()
module.log_remaining_buffer_lines(
buffer_readers,
process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -1044,7 +1134,7 @@ def test_log_remaining_buffer_lines_with_stderr_buffer_elevates_stderr():
module.log_remaining_buffer_lines(
buffer_readers,
process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -1076,7 +1166,7 @@ def test_log_remaining_buffer_lines_with_stderr_buffer_and_capture_stderr_does_n
module.log_remaining_buffer_lines(
buffer_readers,
process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
capture_stderr=True,
)
@@ -1105,13 +1195,13 @@ def test_log_remaining_buffer_lines_with_capture_process_yields_each_line():
module.log_remaining_buffer_lines(
buffer_readers,
process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
) == ('message', 'message')
def test_log_remaining_buffer_lines_with_log_level_and_capture_process_does_not_yield_each_line():
def test_log_remaining_buffer_lines_with_same_log_level_and_capture_process_yields_each_line():
process = flexmock(stderr=flexmock(), args=flexmock())
buffer_readers = {
flexmock(): module.Buffer_reader(
@@ -1124,7 +1214,33 @@ def test_log_remaining_buffer_lines_with_log_level_and_capture_process_does_not_
line=str, log_level=object, elevate_stderr=False, borg_local_path=object, command=object
).and_return(flexmock()).twice()
flexmock(module).should_receive('handle_log_record').and_return(
flexmock(levelno=10, getMessage=lambda: 'message')
flexmock(levelno=module.logging.INFO, getMessage=lambda: 'message')
).twice()
assert tuple(
module.log_remaining_buffer_lines(
buffer_readers,
process_metadatas,
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
) == ('message', 'message')
def test_log_remaining_buffer_lines_with_higher_log_level_and_capture_process_does_not_yield_each_line():
process = flexmock(stderr=flexmock(), args=flexmock())
buffer_readers = {
flexmock(): module.Buffer_reader(
lines=(('hi', 'there'),),
process=process,
)
}
process_metadatas = {process: module.Process_metadata(last_lines=[], capture=True)}
flexmock(module).should_receive('parse_log_line').with_args(
line=str, log_level=object, elevate_stderr=False, borg_local_path=object, command=object
).and_return(flexmock()).twice()
flexmock(module).should_receive('handle_log_record').and_return(
flexmock(levelno=module.logging.DEBUG, getMessage=lambda: 'message')
).twice()
assert (
@@ -1132,7 +1248,7 @@ def test_log_remaining_buffer_lines_with_log_level_and_capture_process_does_not_
module.log_remaining_buffer_lines(
buffer_readers,
process_metadatas,
output_log_level=flexmock(),
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
)
@@ -1140,6 +1256,32 @@ def test_log_remaining_buffer_lines_with_log_level_and_capture_process_does_not_
)
def test_log_remaining_buffer_lines_with_lower_log_level_and_capture_process_does_yields_each_line():
process = flexmock(stderr=flexmock(), args=flexmock())
buffer_readers = {
flexmock(): module.Buffer_reader(
lines=(('hi', 'there'),),
process=process,
)
}
process_metadatas = {process: module.Process_metadata(last_lines=[], capture=True)}
flexmock(module).should_receive('parse_log_line').with_args(
line=str, log_level=object, elevate_stderr=False, borg_local_path=object, command=object
).and_return(flexmock()).twice()
flexmock(module).should_receive('handle_log_record').and_return(
flexmock(levelno=module.logging.ERROR, getMessage=lambda: 'message')
).twice()
assert tuple(
module.log_remaining_buffer_lines(
buffer_readers,
process_metadatas,
output_log_level=module.logging.INFO,
borg_local_path=flexmock(),
)
) == ('message', 'message')
def test_mask_command_secrets_masks_password_flag_value():
assert module.mask_command_secrets(('cooldb', '--username', 'bob', '--password', 'pass')) == (
'cooldb',
@@ -1380,6 +1522,7 @@ def test_execute_command_without_run_to_completion_returns_process():
def test_execute_command_and_capture_output_returns_stdout():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
@@ -1399,8 +1542,10 @@ def test_execute_command_and_capture_output_returns_stdout():
assert output_lines == ('out',)
def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr():
def test_execute_command_and_capture_output_with_capture_stderr_popens_stderr():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
@@ -1422,10 +1567,34 @@ def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr()
assert output_lines == ('out',)
def test_execute_command_and_capture_output_with_borg_command_popens_stderr():
full_command = ['borg', 'list']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(True)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=False,
env=None,
cwd=None,
close_fds=False,
).and_return(process).once()
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('log_outputs').and_yield('out')
output_lines = tuple(module.execute_command_and_capture_output(full_command))
assert output_lines == ('out',)
def test_execute_command_and_capture_output_returns_output_when_process_error_is_not_considered_an_error():
full_command = ['foo', 'bar']
err_output = b'[]'
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
@@ -1439,6 +1608,7 @@ def test_execute_command_and_capture_output_returns_output_when_process_error_is
flexmock(module).should_receive('interpret_exit_code').and_return(
module.Exit_status.SUCCESS,
).once()
flexmock(module.locale).should_receive('getpreferredencoding').and_return('UTF-8')
output_lines = tuple(module.execute_command_and_capture_output(full_command))
@@ -1448,6 +1618,7 @@ def test_execute_command_and_capture_output_returns_output_when_process_error_is
def test_execute_command_and_capture_output_raises_when_command_errors():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
@@ -1461,6 +1632,7 @@ def test_execute_command_and_capture_output_raises_when_command_errors():
flexmock(module).should_receive('interpret_exit_code').and_return(
module.Exit_status.ERROR,
).once()
flexmock(module.locale).should_receive('getpreferredencoding').and_return('UTF-8')
with pytest.raises(subprocess.CalledProcessError):
tuple(module.execute_command_and_capture_output(full_command))
@@ -1469,6 +1641,7 @@ def test_execute_command_and_capture_output_raises_when_command_errors():
def test_execute_command_and_capture_output_with_shell_returns_output():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
'foo bar',
@@ -1491,6 +1664,7 @@ def test_execute_command_and_capture_output_with_shell_returns_output():
def test_execute_command_and_capture_output_with_enviroment_returns_output():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
@@ -1519,6 +1693,7 @@ def test_execute_command_and_capture_output_with_enviroment_returns_output():
def test_execute_command_and_capture_output_returns_output_with_working_directory():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,