Merge branch 'main' into browse-action

This commit is contained in:
Dan Helfman
2026-05-22 20:01:50 -07:00
10 changed files with 138 additions and 18 deletions
+5
View File
@@ -13,6 +13,11 @@
* Add an experimental "browse" action providing a console UI for browsing your backups. See the
documentation for more information:
https://torsion.org/borgmatic/how-to/inspect-your-backups/#browsing-backups
* #1308: Update the Apprise monitoring hook's "url" option to support loading credentials with the
"{credential ...}" syntax. See the documentation for more information:
https://torsion.org/borgmatic/reference/configuration/credentials/
* #1309: Add a "--quick-stats" flag and corresponding "quick_statistics" option for showing only
abbreviated statistics for the "create" action. Borg 1.4.5+ only.
* Update the KeePassXC credential hook to support KeePassXC's secret service integration. See the
documentation for more information:
https://torsion.org/borgmatic/reference/configuration/credentials/keepassxc/
+4 -4
View File
@@ -3,10 +3,10 @@
apprise==1.10.0 # via -r binary_requirements.in
attrs==26.1.0 # via jsonschema, referencing, -r binary_requirements.in
. # via -r binary_requirements.in
certifi==2026.4.22 # via apprise, requests, -r binary_requirements.in
certifi==2026.5.20 # via apprise, requests, -r binary_requirements.in
charset-normalizer==3.4.7 # via requests, -r binary_requirements.in
click==8.3.3 # via apprise, -r binary_requirements.in
idna==3.14 # via requests, -r binary_requirements.in
click==8.4.1 # via apprise, -r binary_requirements.in
idna==3.16 # via requests, -r binary_requirements.in
jsonschema==4.26.0 # via borgmatic, -r binary_requirements.in
jsonschema-specifications==2025.9.1 # via jsonschema, -r binary_requirements.in
markdown==3.10.2 # via apprise, -r binary_requirements.in
@@ -14,7 +14,7 @@ oauthlib==3.3.1 # via requests-oauthlib, -r binary_requirements.in
packaging==26.2 # via borgmatic, -r binary_requirements.in
pyyaml==6.0.3 # via apprise, -r binary_requirements.in
referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r binary_requirements.in
requests==2.34.0 # via apprise, borgmatic, requests-oauthlib, -r binary_requirements.in
requests==2.34.2 # via apprise, borgmatic, requests-oauthlib, -r binary_requirements.in
requests-oauthlib==2.0.0 # via apprise, -r binary_requirements.in
rich==15.0.0
rpds-py==0.30.0 # via jsonschema, referencing, -r binary_requirements.in
+8 -1
View File
@@ -374,7 +374,9 @@ def create_archive(
if json:
output_log_level = None
elif config.get('list_details') or (config.get('statistics') and not dry_run):
elif config.get('list_details') or (
(config.get('statistics') or config.get('quick_statistics')) and not dry_run
):
output_log_level = logging.ANSWER
else:
output_log_level = logging.INFO
@@ -386,6 +388,11 @@ def create_archive(
create_flags += (
(('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ())
+ (('--stats',) if config.get('statistics') and not json and not dry_run else ())
+ (
('--quick-stats',)
if config.get('quick_statistics') and not json and not dry_run
else ()
)
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ())
+ (('--progress',) if config.get('progress') else ())
+ (('--json',) if json else ())
+7
View File
@@ -897,6 +897,13 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915
action='store_true',
help='Display statistics of archive',
)
create_group.add_argument(
'--quick-stats',
dest='quick_statistics',
default=None,
action='store_true',
help='Display statistics of archive, skipping repository-wide "All archives" and chunk index statistics [Borg 1.4.5+ only]',
)
create_group.add_argument(
'--list',
'--files',
+12 -1
View File
@@ -1091,6 +1091,15 @@ properties:
Corresponds to the "--stats" flag on those actions. Defaults to
false.
example: true
quick_statistics:
type: boolean
description: |
Display statistics for an archive when running supported actions,
skipping the repository-wide "All archives" and chunk index
statistics to save some time. Corresponds to the "--quick-stats"
flag on those actions. Defaults to false. (This option is supported
for Borg 1.4.5+ only.)
example: true
list_details:
type: boolean
description: |
@@ -2828,7 +2837,9 @@ properties:
properties:
url:
type: string
description: URL of this Apprise service.
description: |
URL of this Apprise service. Supports the
"{credential ...}" syntax.
example: "gotify://hostname/token"
label:
type: string
+7 -1
View File
@@ -1,6 +1,7 @@
import logging
import operator
import borgmatic.hooks.credential.parse
import borgmatic.hooks.monitoring.logs
import borgmatic.hooks.monitoring.monitor
@@ -78,7 +79,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
logger.info(f'Pinging Apprise services: {labels_string}{dry_run_string}')
apprise_object = apprise.Apprise()
apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services'))))
apprise_object.add(
[
borgmatic.hooks.credential.parse.resolve_credential(service['url'], config)
for service in hook_config.get('services')
]
)
if dry_run:
return
@@ -5,9 +5,9 @@ eleventyNavigation:
parent: 🚨 Monitoring
---
<span class="minilink minilink-addedin">New in version 1.8.4</span>
[Apprise](https://github.com/caronc/apprise/wiki) is a local notification library
[Apprise](https://appriseit.com/) is a local notification library
that "allows you to send a notification to almost all of the most popular
[notification services](https://github.com/caronc/apprise/wiki) available to
[notification services](https://appriseit.com/services/) available to
us today such as: Telegram, Discord, Slack, Amazon SNS, Gotify, etc."
Depending on how you installed borgmatic, it may not have come with Apprise.
@@ -22,7 +22,7 @@ sudo uv tool install borgmatic[Apprise]
Omit `sudo` if borgmatic is installed as a non-root user.
Once Apprise is installed, configure borgmatic to notify one or more [Apprise
services](https://github.com/caronc/apprise/wiki). For example:
services](https://appriseit.com/services/). For example:
```yaml
apprise:
+3 -3
View File
@@ -2,13 +2,13 @@
# uv pip compile --annotation-style line test_requirements.in -o test_requirements.txt
apprise==1.10.0 # via -r test_requirements.in
attrs==26.1.0 # via jsonschema, referencing, -r test_requirements.in
certifi==2026.4.22 # via apprise, requests, -r test_requirements.in
certifi==2026.5.20 # via apprise, requests, -r test_requirements.in
charset-normalizer==3.4.7 # via requests, -r test_requirements.in
click>=8.1.8
codespell==2.4.2 # via -r test_requirements.in
coverage==7.14.0 # via pytest-cov, -r test_requirements.in
flexmock==0.13.0 # via -r test_requirements.in
idna==3.14 # via requests, -r test_requirements.in
idna==3.16 # via requests, -r test_requirements.in
iniconfig==2.3.0 # via pytest, -r test_requirements.in
jsonschema==4.26.0 # via -r test_requirements.in
jsonschema-specifications==2025.9.1 # via jsonschema, -r test_requirements.in
@@ -23,7 +23,7 @@ pytest-cov==7.1.0 # via -r test_requirements.in
pytest-timeout==2.4.0 # via -r test_requirements.in
pyyaml>5.0.0
referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r test_requirements.in
requests==2.34.0 # via apprise, requests-oauthlib, -r test_requirements.in
requests==2.34.2 # via apprise, requests-oauthlib, -r test_requirements.in
requests-oauthlib==2.0.0 # via apprise, -r test_requirements.in
rich==15.0.0
rpds-py==0.30.0 # via jsonschema, referencing, -r test_requirements.in
+74
View File
@@ -1479,6 +1479,44 @@ def test_create_archive_with_stats_and_dry_run_calls_borg_without_stats():
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
'statistics': True,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
global_arguments=flexmock(),
borgmatic_runtime_directory='/borgmatic/run',
)
def test_create_archive_with_quick_stats_and_dry_run_calls_borg_without_quick_stats():
# --dry-run and --quick-stats are mutually exclusive, see:
# https://borgbackup.readthedocs.io/en/stable/usage/create.html#description
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module).should_receive('make_base_create_command').and_return(
(('borg', 'create', '--dry-run'), REPO_ARCHIVE, flexmock()),
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--dry-run', '--info', *REPO_ARCHIVE),
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
borg_exit_codes=None,
working_directory=None,
environment=None,
)
insert_logging_mock(logging.INFO)
module.create_archive(
dry_run=True,
repository_path='repo',
config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
'quick_statistics': True,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
@@ -1816,6 +1854,42 @@ def test_create_archive_with_stats_and_json_calls_borg_without_stats_flag():
'source_directories': ['foo*'],
'repositories': ['repo'],
'exclude_patterns': None,
'statistics': True,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
global_arguments=flexmock(),
borgmatic_runtime_directory='/borgmatic/run',
json=True,
)
assert json_output == '[]'
def test_create_archive_with_quick_stats_and_json_calls_borg_without_quick_stats_flag():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module).should_receive('make_base_create_command').and_return(
(('borg', 'create'), REPO_ARCHIVE, flexmock()),
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'create', '--json', *REPO_ARCHIVE),
working_directory=None,
environment=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_yield('[]')
json_output = module.create_archive(
dry_run=False,
repository_path='repo',
config={
'source_directories': ['foo*'],
'repositories': ['repo'],
'exclude_patterns': None,
'quick_statistics': True,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
+15 -5
View File
@@ -66,7 +66,9 @@ def test_initialize_monitor_without_send_logs_does_not_add_handler():
def test_ping_monitor_respects_dry_run():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).and_return('loggy log')
@@ -83,7 +85,9 @@ def test_ping_monitor_respects_dry_run():
def test_ping_monitor_with_no_states_does_not_notify():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).never()
@@ -100,7 +104,9 @@ def test_ping_monitor_with_no_states_does_not_notify():
def test_ping_monitor_notifies_fail_by_default():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).and_return('')
@@ -123,7 +129,9 @@ def test_ping_monitor_notifies_fail_by_default():
def test_ping_monitor_with_logs_appends_logs_to_body():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).and_return('loggy log')
@@ -146,7 +154,9 @@ def test_ping_monitor_with_logs_appends_logs_to_body():
def test_ping_monitor_with_finish_default_config_notifies():
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda url, config: url)
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload',
).and_return('')