Compare commits

..
69 Commits
Author SHA1 Message Date
Dan Helfman 1197d6d0f6 Bump version for release. 2024-08-29 17:41:02 -07:00
Dan Helfman 288a4bf243 : Add documentation details for how to run custom database dump commands using binaries from running containers (#906). 2024-08-20 15:03:49 -07:00
Dan Helfman 1fb943e5f1 Documentation and config schema update about ~/.borgmatic volume mount (#906).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/907
2024-08-20 21:51:51 +00:00
Dan Helfman e0298685a1 Fix the "source_directories_must_exist" option to work with relative "source_directories" paths when a "working_directory" is set (#905). 2024-08-20 13:52:06 -07:00
Dan Helfman c5633227bf Add glob ("*") support to the "--repository" flag (#898). 2024-08-20 12:49:50 -07:00
Jonathan Brockhaus 6087c12e09 docs: typo 2024-08-16 14:36:28 +02:00
Jonathan Brockhaus 9d83f02e24 fix(config/schema.yaml): Add note about mounting .borgmatic folder inside container db backups 2024-08-16 14:25:11 +02:00
Jonathan Brockhaus b6ccde6757 fix(docs): Add note about mounting .borgmatic folder inside container db backups 2024-08-16 14:23:59 +02:00
Dan Helfman 548aceb3d5 Fix config validation broken by schema change (#904). 2024-08-12 23:09:55 -07:00
Dan Helfman 2bd63bbdd2 Clarify the configuration reference about the "spot" check options (#904). 2024-08-12 22:50:58 -07:00
Dan Helfman 9b4fed64a6 Upgrade run-full-tests dependencies. 2024-08-11 20:41:43 -07:00
Dan Helfman ff1001a5f5 Fix NEWS typo. 2024-08-11 20:38:38 -07:00
Dan Helfman e7f1c3eda5 Add "color" option regression fix to NEWS. 2024-08-11 20:35:54 -07:00
Dan HelfmanandGitHub 21e343a948 Fix broken color configuration option.
Merge pull request #83 from TheoBrigitte/main
2024-08-11 20:32:43 -07:00
Theo Brigitte 4c4fd92013 update logger unit tests
* update color config structure
  * add check for color: True in config
  * ensure interactive_console is called wherever it should
2024-08-09 00:07:46 +02:00
Theo Brigitte 37735e464c Fix color configuration directive 2024-08-05 16:38:49 +02:00
Dan Helfman 424cc6b66c Fix for a "bad character" Borg error in which the "spot" check fed Borg an invalid pattern (#899). 2024-07-25 11:34:09 -07:00
Dan Helfman e128a3e0a9 Add a ticket number onto the traceback fix in NEWS. 2024-07-19 22:22:19 -07:00
Dan Helfman 27e7ece2f5 More fix for a potential traceback (TypeError) during the handling of another error. 2024-07-15 14:45:21 -07:00
Dan Helfman d44dc93509 Fix for a potential traceback (TypeError) during the handling of another error. 2024-07-14 10:07:15 -07:00
Dan Helfman 31778fd3bf Fix error in borgmatic rcreate/init on empty repository directory with Borg 1.4 (#896). 2024-07-08 11:24:48 -07:00
Dan Helfman 4313f90dd8 Add a couple of more code style guidelines. 2024-07-07 20:49:27 -07:00
Dan Helfman 1f82ea2798 Attempt to fix documentation build. 2024-07-06 10:23:10 -07:00
Dan Helfman 148536d867 Upgrade certifi in test requirements. 2024-07-06 10:17:53 -07:00
Dan Helfman 197f0a521d Add a recent contributors section to the documentation. 2024-07-06 10:15:16 -07:00
Dan Helfman 0d8e352033 Update documentation link to Borg 1.4.0 exit codes. 2024-07-04 20:21:40 -07:00
Dan Helfman 20a3995977 Bump version for release. 2024-07-04 08:54:09 -07:00
Dan Helfman 66aa953371 Add additional tests (#298). 2024-07-04 08:42:05 -07:00
Dan Helfman ba053de8f7 Add delete and rdelete actions (#298).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/893
2024-07-04 06:07:30 +00:00
Dan Helfman 2f844d65d5 Automated tests complete (#298). 2024-07-02 23:11:58 -07:00
Dan Helfman 2dca5e1834 Initial automated tests for delete action (#298). 2024-07-02 20:26:29 -07:00
Dan Helfman 36197ce027 Dry run support for delete/rdelete actions (#298). 2024-06-28 20:56:42 -07:00
Dan Helfman e9a0226ee0 Initial work on delete/rdelete actions (#298). 2024-06-28 16:20:10 -07:00
Dan Helfman fc3b4a653e Add a development documentation note about using fully qualified imports. 2024-06-28 15:00:25 -07:00
Dan Helfman 3673abb01e Update documentation links that link to beta versions of Borg docs. 2024-06-27 14:42:53 -07:00
Dan Helfman ac4277d36c Add "new in version" label to Uptime Kuma documentation (#885). 2024-06-27 09:29:53 -07:00
Dan Helfman 21cbc99d9e Fix uptime_kuma monitor not being called after name change (#891).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/891
2024-06-27 16:27:07 +00:00
Paul Wilde d080bf2ae9 [bugfix] uptime_kuma monitor is not called after name change 2024-06-27 12:49:07 +01:00
Dan Helfman 2a1c790655 Minor reorganization of borgmatic source code reference. 2024-06-26 19:39:32 -07:00
Dan Helfman 410204a70d Formatting, whitespace, and minor fixes for Uptime Kuma hook (#885). 2024-06-26 16:09:14 -07:00
Dan Helfman 4a0c167c1c Add Uptime Kuma monitoring hook (#885).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/885
Reviewed-by: Dan Helfman <witten@torsion.org>
2024-06-26 22:50:11 +00:00
Dan Helfman 593c956d33 Add an "only_run_on" option to consistency checks so you can limit a check to running on particular days of the week (#785). 2024-06-26 14:57:59 -07:00
pswilde d18cb89493 Merge branch 'main' into main 2024-06-26 19:58:56 +00:00
Paul Wilde 067c79c606 renamed push_monitor back to ping_monitor in uptime kuma hook 2024-06-26 20:57:37 +01:00
Dan Helfman ebde88ccaa Fix the Healthchecks ping body size limit, restoring it to the documented 100,000 bytes (#889). 2024-06-25 12:45:44 -07:00
Dan Helfman cc402487d9 Minor development documentation clarifications. 2024-06-24 10:48:13 -07:00
Paul Wilde d108e6102b some minor corrections in how to uptime kuma docs 2024-06-24 12:00:22 +01:00
Paul Wilde 3e60043632 some minor corrections in how to uptime kuma docs 2024-06-24 11:57:12 +01:00
Paul Wilde a8d691169a some minor corrections in how to uptime kuma docs 2024-06-24 11:54:34 +01:00
Paul Wilde 939c2f6718 some minor corrections in how to uptime kuma docs 2024-06-24 11:54:12 +01:00
Paul Wilde 0837059e21 some minor corrections in how to uptime kuma docs 2024-06-24 11:51:41 +01:00
Paul Wilde 0ee166fdf0 added Uptime Kuma how-to docs 2024-06-24 11:46:38 +01:00
Paul Wilde b50996b864 change uptimekuma method names to 'push_*' instead of 'ping' 2024-06-24 11:07:09 +01:00
Paul Wilde 8f423c7293 black formatting on uptimekuma hook 2024-06-24 10:46:04 +01:00
Paul Wilde 14ce88e04b black formatting on test_uptimekuma.py 2024-06-24 10:37:44 +01:00
Paul Wilde f97968b72d variable renaming 2024-06-24 10:34:52 +01:00
Paul Wilde 612f867ea8 Merge remote-tracking branch 'upstream/main' 2024-06-24 10:28:02 +01:00
Paul Wilde 303d6609e4 removed unecessary tests for 'default urls' as these would never really be used anyway 2024-06-24 10:27:23 +01:00
Paul Wilde bf7b163ccd added early out for dry run 2024-06-24 10:25:08 +01:00
Paul Wilde 4bd798f0ad alpha ordered dispatch monitor hook names (including loki) 2024-06-24 10:22:27 +01:00
Paul Wilde 52aa7c5d21 switched to using full 'push_url' instead of separate 'server' and 'push_code' 2024-06-24 10:21:31 +01:00
Dan Helfman f5a1dd31c8 Fix PagerDuty hook traceback with Python < 3.10 (#886). 2024-06-23 18:28:41 -07:00
Paul Wilde a509cdedd5 Added Uptime Kuma tests 2024-06-22 10:46:17 +01:00
Paul Wilde dcbc30b164 WIP add uptime kuma tests 2024-06-22 10:19:34 +01:00
Paul Wilde 5ab99b4cc0 Added Uptime Kuma image to readme 2024-06-21 21:21:04 +01:00
Paul Wilde 27c90b7cf1 Added Uptime Kuma hook 2024-06-21 21:10:14 +01:00
Paul Wilde 6eb76454bb WIP added some schema info for uptime kuma 2024-06-21 17:00:44 +01:00
Paul Wilde 83bcea98dc WIP added some schema info for uptime kuma 2024-06-21 16:57:20 +01:00
Paul Wilde 4db09a73b3 WIP implement Uptime Kuma hook 2024-06-21 16:47:51 +01:00
50 changed files with 2225 additions and 220 deletions
+27
View File
@@ -1,3 +1,30 @@
1.8.14
* #896: Fix an error in borgmatic rcreate/init on an empty repository directory with Borg 1.4.
* #898: Add glob ("*") support to the "--repository" flag. Just quote any values containing
globs so your shell doesn't interpret them.
* #899: Fix for a "bad character" Borg error in which the "spot" check fed Borg an invalid pattern.
* #900: Fix for a potential traceback (TypeError) during the handling of another error.
* #904: Clarify the configuration reference about the "spot" check options:
https://torsion.org/borgmatic/docs/reference/configuration/
* #905: Fix the "source_directories_must_exist" option to work with relative "source_directories"
paths when a "working_directory" is set.
* #906: Add documentation details for how to run custom database dump commands using binaries from
running containers:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
* Fix a regression in which the "color" option had no effect.
* Add a recent contributors section to the documentation, because credit where credit's due! See:
https://torsion.org/borgmatic/#recent-contributors
1.8.13
* #298: Add "delete" and "rdelete" actions to delete archives or entire repositories.
* #785: Add an "only_run_on" option to consistency checks so you can limit a check to running on
particular days of the week. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#check-days
* #885: Add an Uptime Kuma monitoring hook. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptime-kuma-hook
* #886: Fix a PagerDuty hook traceback with Python < 3.10.
* #889: Fix the Healthchecks ping body size limit, restoring it to the documented 100,000 bytes.
1.8.12
* #817: Add a "--max-duration" flag to the "check" action and a "max_duration" option to the
repository check configuration. This tells Borg to interrupt a repository check after a certain
+5
View File
@@ -62,6 +62,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
@@ -154,3 +155,7 @@ general, contributions are very welcome. We don't bite!
Also, please check out the [borgmatic development
how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for
info on cloning source code, running tests, etc.
### Recent contributors
{% include borgmatic/contributors.html %}
+31 -2
View File
@@ -1,3 +1,4 @@
import calendar
import datetime
import hashlib
import itertools
@@ -99,12 +100,17 @@ def parse_frequency(frequency):
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
WEEKDAY_DAYS = calendar.day_name[0:5]
WEEKEND_DAYS = calendar.day_name[5:7]
def filter_checks_on_frequency(
config,
borg_repository_id,
checks,
force,
archives_check_id=None,
datetime_now=datetime.datetime.now,
):
'''
Given a configuration dict with a "checks" sequence of dicts, a Borg repository ID, a sequence
@@ -143,6 +149,29 @@ def filter_checks_on_frequency(
if checks and check not in checks:
continue
only_run_on = check_config.get('only_run_on')
if only_run_on:
# Use a dict instead of a set to preserve ordering.
days = dict.fromkeys(only_run_on)
if 'weekday' in days:
days = {
**dict.fromkeys(day for day in days if day != 'weekday'),
**dict.fromkeys(WEEKDAY_DAYS),
}
if 'weekend' in days:
days = {
**dict.fromkeys(day for day in days if day != 'weekend'),
**dict.fromkeys(WEEKEND_DAYS),
}
if calendar.day_name[datetime_now().weekday()] not in days:
logger.info(
f"Skipping {check} check due to day of the week; check only runs on {'/'.join(days)} (use --force to check anyway)"
)
filtered_checks.remove(check)
continue
frequency_delta = parse_frequency(check_config.get('frequency'))
if not frequency_delta:
continue
@@ -153,8 +182,8 @@ def filter_checks_on_frequency(
# If we've not yet reached the time when the frequency dictates we're ready for another
# check, skip this check.
if datetime.datetime.now() < check_time + frequency_delta:
remaining = check_time + frequency_delta - datetime.datetime.now()
if datetime_now() < check_time + frequency_delta:
remaining = check_time + frequency_delta - datetime_now()
logger.info(
f'Skipping {check} check due to configured frequency; {remaining} until next check (use --force to check anyway)'
)
+50
View File
@@ -0,0 +1,50 @@
import logging
import borgmatic.actions.arguments
import borgmatic.borg.delete
import borgmatic.borg.rdelete
import borgmatic.borg.rlist
logger = logging.getLogger(__name__)
def run_delete(
repository,
config,
local_borg_version,
delete_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "delete" action for the given repository and archive(s).
'''
if delete_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, delete_arguments.repository
):
logger.answer(f'{repository.get("label", repository["path"])}: Deleting archives')
archive_name = (
borgmatic.borg.rlist.resolve_archive_name(
repository['path'],
delete_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
if delete_arguments.archive
else None
)
borgmatic.borg.delete.delete_archives(
repository,
config,
local_borg_version,
borgmatic.actions.arguments.update_arguments(delete_arguments, archive=archive_name),
global_arguments,
local_path,
remote_path,
)
+36
View File
@@ -0,0 +1,36 @@
import logging
import borgmatic.borg.rdelete
logger = logging.getLogger(__name__)
def run_rdelete(
repository,
config,
local_borg_version,
rdelete_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "rdelete" action for the given repository.
'''
if rdelete_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, rdelete_arguments.repository
):
logger.answer(
f'{repository.get("label", repository["path"])}: Deleting repository'
+ (' cache' if rdelete_arguments.cache_only else '')
)
borgmatic.borg.rdelete.delete_repository(
repository,
config,
local_borg_version,
rdelete_arguments,
global_arguments,
local_path,
remote_path,
)
+2 -1
View File
@@ -147,7 +147,8 @@ def collect_archive_data_source_names(
local_borg_version,
global_arguments,
list_paths=[
os.path.expanduser(
'sh:'
+ os.path.expanduser(
borgmatic.hooks.dump.make_data_source_dump_path(borgmatic_source_directory, pattern)
)
for pattern in ('*_databases/*/*',)
+22 -10
View File
@@ -306,15 +306,25 @@ def collect_special_file_paths(
)
def check_all_source_directories_exist(source_directories):
def check_all_source_directories_exist(source_directories, working_directory=None):
'''
Given a sequence of source directories, check that they all exist. If any do not, raise an
exception.
Given a sequence of source directories and an optional working directory to serve as a prefix
for each (if it's a relative directory), check that the source directories all exist. If any do
not, raise an exception.
'''
missing_directories = [
source_directory
for source_directory in source_directories
if not all([os.path.exists(directory) for directory in expand_directory(source_directory)])
if not all(
[
os.path.exists(directory)
for directory in expand_directory(
os.path.join(working_directory, source_directory)
if working_directory
else source_directory
)
]
)
]
if missing_directories:
raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}")
@@ -342,8 +352,15 @@ def make_base_create_command(
(base Borg create command flags, Borg create command positional arguments, open pattern file
handle, open exclude file handle).
'''
try:
working_directory = os.path.expanduser(config.get('working_directory'))
except TypeError:
working_directory = None
if config.get('source_directories_must_exist', False):
check_all_source_directories_exist(config.get('source_directories'))
check_all_source_directories_exist(
config.get('source_directories'), working_directory=working_directory
)
sources = deduplicate_directories(
map_directories_to_devices(
@@ -445,11 +462,6 @@ def make_base_create_command(
logger.warning(
f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
)
try:
working_directory = os.path.expanduser(config.get('working_directory'))
except TypeError:
working_directory = None
borg_environment = environment.make_environment(config)
logger.debug(f'{repository_path}: Collecting special file paths')
+132
View File
@@ -0,0 +1,132 @@
import argparse
import logging
import borgmatic.borg.environment
import borgmatic.borg.feature
import borgmatic.borg.flags
import borgmatic.borg.rdelete
import borgmatic.execute
logger = logging.getLogger(__name__)
def make_delete_command(
repository,
config,
local_borg_version,
delete_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Given a local or remote repository dict, a configuration dict, the local Borg version, the
arguments to the delete action as an argparse.Namespace, and global arguments, return a command
as a tuple to delete archives from the repository.
'''
return (
(local_path, 'delete')
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ borgmatic.borg.flags.make_flags('list', delete_arguments.list_archives)
+ (
(('--force',) + (('--force',) if delete_arguments.force >= 2 else ()))
if delete_arguments.force
else ()
)
# Ignore match_archives and archive_name_format options from configuration, so the user has
# to be explicit on the command-line about the archives they want to delete.
+ borgmatic.borg.flags.make_match_archives_flags(
delete_arguments.match_archives or delete_arguments.archive,
archive_name_format=None,
local_borg_version=local_borg_version,
default_archive_name_format='*',
)
+ borgmatic.borg.flags.make_flags_from_arguments(
delete_arguments,
excludes=('list_archives', 'force', 'match_archives', 'archive', 'repository'),
)
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
)
ARCHIVE_RELATED_ARGUMENT_NAMES = (
'archive',
'match_archives',
'first',
'last',
'oldest',
'newest',
'older',
'newer',
)
def delete_archives(
repository,
config,
local_borg_version,
delete_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository dict, a configuration dict, the local Borg version, the
arguments to the delete action as an argparse.Namespace, global arguments as an
argparse.Namespace, and local and remote Borg paths, delete the selected archives from the
repository. If no archives are selected, then delete the entire repository.
'''
borgmatic.logger.add_custom_log_levels()
if not any(
getattr(delete_arguments, argument_name, None)
for argument_name in ARCHIVE_RELATED_ARGUMENT_NAMES
):
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.RDELETE, local_borg_version
):
logger.warning(
'Deleting an entire repository with the delete action is deprecated when using Borg 2.x+. Use the rdelete action instead.'
)
rdelete_arguments = argparse.Namespace(
repository=repository['path'],
list_archives=delete_arguments.list_archives,
force=delete_arguments.force,
cache_only=delete_arguments.cache_only,
keep_security_info=delete_arguments.keep_security_info,
)
borgmatic.borg.rdelete.delete_repository(
repository,
config,
local_borg_version,
rdelete_arguments,
global_arguments,
local_path,
remote_path,
)
return
command = make_delete_command(
repository,
config,
local_borg_version,
delete_arguments,
global_arguments,
local_path,
remote_path,
)
borgmatic.execute.execute_command(
command,
output_log_level=logging.ANSWER,
extra_environment=borgmatic.borg.environment.make_environment(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+4 -2
View File
@@ -13,8 +13,9 @@ class Feature(Enum):
RCREATE = 7
RLIST = 8
RINFO = 9
MATCH_ARCHIVES = 10
EXCLUDED_FILES_MINUS = 11
RDELETE = 10
MATCH_ARCHIVES = 11
EXCLUDED_FILES_MINUS = 12
FEATURE_TO_MINIMUM_BORG_VERSION = {
@@ -27,6 +28,7 @@ FEATURE_TO_MINIMUM_BORG_VERSION = {
Feature.RCREATE: parse('2.0.0a2'), # borg rcreate
Feature.RLIST: parse('2.0.0a2'), # borg rlist
Feature.RINFO: parse('2.0.0a2'), # borg rinfo
Feature.RDELETE: parse('2.0.0a2'), # borg rdelete
Feature.MATCH_ARCHIVES: parse('2.0.0b3'), # borg --match-archives
Feature.EXCLUDED_FILES_MINUS: parse('2.0.0b5'), # --list --filter uses "-" for excludes
}
+7 -2
View File
@@ -66,7 +66,12 @@ def make_repository_archive_flags(repository_path, archive, local_borg_version):
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003
def make_match_archives_flags(match_archives, archive_name_format, local_borg_version):
def make_match_archives_flags(
match_archives,
archive_name_format,
local_borg_version,
default_archive_name_format=DEFAULT_ARCHIVE_NAME_FORMAT,
):
'''
Return match archives flags based on the given match archives value, if any. If it isn't set,
return match archives flags to match archives created with the given (or default) archive name
@@ -83,7 +88,7 @@ def make_match_archives_flags(match_archives, archive_name_format, local_borg_ve
return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
derived_match_archives = re.sub(
r'\{(now|utcnow|pid)([:%\w\.-]*)\}', '*', archive_name_format or DEFAULT_ARCHIVE_NAME_FORMAT
r'\{(now|utcnow|pid)([:%\w\.-]*)\}', '*', archive_name_format or default_archive_name_format
)
if derived_match_archives == '*':
+12 -11
View File
@@ -100,10 +100,11 @@ def capture_archive_listing(
remote_path=None,
):
'''
Given a local or remote repository path, an archive name, a configuration dict, the local Borg
version, global arguments as an argparse.Namespace, the archive paths in which to list files,
the Borg path format to use for the output, and local and remote Borg paths, capture the output
of listing that archive and return it as a list of file paths.
Given a local or remote repository path, an archive name, a configuration
dict, the local Borg version, global arguments as an argparse.Namespace,
the archive paths (or Borg patterns) in which to list files, the Borg path
format to use for the output, and local and remote Borg paths, capture the
output of listing that archive and return it as a list of file paths.
'''
borg_environment = environment.make_environment(config)
@@ -116,7 +117,7 @@ def capture_archive_listing(
argparse.Namespace(
repository=repository_path,
archive=archive,
paths=[f'sh:{path}' for path in list_paths] if list_paths else None,
paths=[path for path in list_paths] if list_paths else None,
find_paths=None,
json=None,
format=path_format or '{path}{NL}', # noqa: FS003
@@ -144,12 +145,12 @@ def list_archive(
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, global
arguments as an argparse.Namespace, the arguments to the list action as an argparse.Namespace,
and local and remote Borg paths, display the output of listing the files of a Borg archive (or
return JSON output). If list_arguments.find_paths are given, list the files by searching across
multiple archives. If neither find_paths nor archive name are given, instead list the archives
in the given repository.
Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments to the list action as an argparse.Namespace, global arguments as an
argparse.Namespace, and local and remote Borg paths, display the output of listing the files of
a Borg archive (or return JSON output). If list_arguments.find_paths are given, list the files
by searching across multiple archives. If neither find_paths nor archive name are given, instead
list the archives in the given repository.
'''
borgmatic.logger.add_custom_log_levels()
+1 -1
View File
@@ -9,7 +9,7 @@ from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
RINFO_REPOSITORY_NOT_FOUND_EXIT_CODES = {2, 13}
RINFO_REPOSITORY_NOT_FOUND_EXIT_CODES = {2, 13, 15}
def create_repository(
+92
View File
@@ -0,0 +1,92 @@
import logging
import borgmatic.borg.environment
import borgmatic.borg.feature
import borgmatic.borg.flags
import borgmatic.execute
logger = logging.getLogger(__name__)
def make_rdelete_command(
repository,
config,
local_borg_version,
rdelete_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Given a local or remote repository dict, a configuration dict, the local Borg version, the
arguments to the rdelete action as an argparse.Namespace, and global arguments, return a command
as a tuple to rdelete the entire repository.
'''
return (
(local_path,)
+ (
('rdelete',)
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.RDELETE, local_borg_version
)
else ('delete',)
)
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ borgmatic.borg.flags.make_flags('list', rdelete_arguments.list_archives)
+ (
(('--force',) + (('--force',) if rdelete_arguments.force >= 2 else ()))
if rdelete_arguments.force
else ()
)
+ borgmatic.borg.flags.make_flags_from_arguments(
rdelete_arguments, excludes=('list_archives', 'force', 'repository')
)
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
)
def delete_repository(
repository,
config,
local_borg_version,
rdelete_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository dict, a configuration dict, the local Borg version, the
arguments to the rdelete action as an argparse.Namespace, global arguments as an
argparse.Namespace, and local and remote Borg paths, rdelete the entire repository.
'''
borgmatic.logger.add_custom_log_levels()
command = make_rdelete_command(
repository,
config,
local_borg_version,
rdelete_arguments,
global_arguments,
local_path,
remote_path,
)
borgmatic.execute.execute_command(
command,
output_log_level=logging.ANSWER,
# Don't capture output when Borg is expected to prompt for interactive confirmation, or the
# prompt won't work.
output_file=(
None
if rdelete_arguments.force or rdelete_arguments.cache_only
else borgmatic.execute.DO_NOT_CAPTURE
),
extra_environment=borgmatic.borg.environment.make_environment(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+149 -19
View File
@@ -12,11 +12,13 @@ ACTION_ALIASES = {
'create': ['-C'],
'check': ['-k'],
'config': [],
'delete': [],
'extract': ['-x'],
'export-tar': [],
'mount': ['-m'],
'umount': ['-u'],
'restore': ['-r'],
'rdelete': [],
'rlist': [],
'list': ['-l'],
'rinfo': [],
@@ -423,7 +425,7 @@ def make_parsers():
)
rcreate_group.add_argument(
'--repository',
help='Path of the new repository to create (must be already specified in a borgmatic configuration file), defaults to the configured repository if there is only one',
help='Path of the new repository to create (must be already specified in a borgmatic configuration file), defaults to the configured repository if there is only one, quoted globs supported',
)
rcreate_group.add_argument(
'--copy-crypt-key',
@@ -458,7 +460,7 @@ def make_parsers():
transfer_group = transfer_parser.add_argument_group('transfer arguments')
transfer_group.add_argument(
'--repository',
help='Path of existing destination repository to transfer archives to, defaults to the configured repository if there is only one',
help='Path of existing destination repository to transfer archives to, defaults to the configured repository if there is only one, quoted globs supported',
)
transfer_group.add_argument(
'--source-repository',
@@ -531,14 +533,14 @@ def make_parsers():
prune_group = prune_parser.add_argument_group('prune arguments')
prune_group.add_argument(
'--repository',
help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file)',
help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file), quoted globs supported',
)
prune_group.add_argument(
'--stats',
dest='stats',
default=False,
action='store_true',
help='Display statistics of archive',
help='Display statistics of the pruned archive',
)
prune_group.add_argument(
'--list', dest='list_archives', action='store_true', help='List archives kept/pruned'
@@ -575,7 +577,7 @@ def make_parsers():
compact_group = compact_parser.add_argument_group('compact arguments')
compact_group.add_argument(
'--repository',
help='Path of specific existing repository to compact (must be already specified in a borgmatic configuration file)',
help='Path of specific existing repository to compact (must be already specified in a borgmatic configuration file), quoted globs supported',
)
compact_group.add_argument(
'--progress',
@@ -611,7 +613,7 @@ def make_parsers():
create_group = create_parser.add_argument_group('create arguments')
create_group.add_argument(
'--repository',
help='Path of specific existing repository to backup to (must be already specified in a borgmatic configuration file)',
help='Path of specific existing repository to backup to (must be already specified in a borgmatic configuration file), quoted globs supported',
)
create_group.add_argument(
'--progress',
@@ -645,7 +647,7 @@ def make_parsers():
check_group = check_parser.add_argument_group('check arguments')
check_group.add_argument(
'--repository',
help='Path of specific existing repository to check (must be already specified in a borgmatic configuration file)',
help='Path of specific existing repository to check (must be already specified in a borgmatic configuration file), quoted globs supported',
)
check_group.add_argument(
'--progress',
@@ -689,6 +691,97 @@ def make_parsers():
)
check_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
delete_parser = action_parsers.add_parser(
'delete',
aliases=ACTION_ALIASES['delete'],
help='Delete an archive from a repository or delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
description='Delete an archive from a repository or delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
add_help=False,
)
delete_group = delete_parser.add_argument_group('delete arguments')
delete_group.add_argument(
'--repository',
help='Path of repository to delete or delete archives from, defaults to the configured repository if there is only one, quoted globs supported',
)
delete_group.add_argument(
'--archive',
help='Archive to delete',
)
delete_group.add_argument(
'--list',
dest='list_archives',
action='store_true',
help='Show details for the deleted archives',
)
delete_group.add_argument(
'--stats',
action='store_true',
help='Display statistics for the deleted archives',
)
delete_group.add_argument(
'--cache-only',
action='store_true',
help='Delete only the local cache for the given repository',
)
delete_group.add_argument(
'--force',
action='count',
help='Force deletion of corrupted archives, can be given twice if once does not work',
)
delete_group.add_argument(
'--keep-security-info',
action='store_true',
help='Do not delete the local security info when deleting a repository',
)
delete_group.add_argument(
'--save-space',
action='store_true',
help='Work slower, but using less space [Not supported in Borg 2.x+]',
)
delete_group.add_argument(
'--checkpoint-interval',
type=int,
metavar='SECONDS',
help='Write a checkpoint at the given interval, defaults to 1800 seconds (30 minutes)',
)
delete_group.add_argument(
'-a',
'--match-archives',
'--glob-archives',
metavar='PATTERN',
help='Only delete archives matching this pattern',
)
delete_group.add_argument(
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
)
delete_group.add_argument(
'--first', metavar='N', help='Delete first N archives after other filters are applied'
)
delete_group.add_argument(
'--last', metavar='N', help='Delete last N archives after other filters are applied'
)
delete_group.add_argument(
'--oldest',
metavar='TIMESPAN',
help='Delete archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]',
)
delete_group.add_argument(
'--newest',
metavar='TIMESPAN',
help='Delete archives within a time range that ends at timestamp of the newest archive and starts a specified time range ago (e.g. 7d or 12m) [Borg 2.x+ only]',
)
delete_group.add_argument(
'--older',
metavar='TIMESPAN',
help='Delete archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
)
delete_group.add_argument(
'--newer',
metavar='TIMESPAN',
help='Delete archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
)
delete_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
extract_parser = action_parsers.add_parser(
'extract',
aliases=ACTION_ALIASES['extract'],
@@ -699,7 +792,7 @@ def make_parsers():
extract_group = extract_parser.add_argument_group('extract arguments')
extract_group.add_argument(
'--repository',
help='Path of repository to extract, defaults to the configured repository if there is only one',
help='Path of repository to extract, defaults to the configured repository if there is only one, quoted globs supported',
)
extract_group.add_argument(
'--archive', help='Name of archive to extract (or "latest")', required=True
@@ -761,7 +854,7 @@ def make_parsers():
)
config_bootstrap_group.add_argument(
'--repository',
help='Path of repository to extract config files from',
help='Path of repository to extract config files from, quoted globs supported',
required=True,
)
config_bootstrap_group.add_argument(
@@ -859,7 +952,7 @@ def make_parsers():
export_tar_group = export_tar_parser.add_argument_group('export-tar arguments')
export_tar_group.add_argument(
'--repository',
help='Path of repository to export from, defaults to the configured repository if there is only one',
help='Path of repository to export from, defaults to the configured repository if there is only one, quoted globs supported',
)
export_tar_group.add_argument(
'--archive', help='Name of archive to export (or "latest")', required=True
@@ -905,7 +998,7 @@ def make_parsers():
mount_group = mount_parser.add_argument_group('mount arguments')
mount_group.add_argument(
'--repository',
help='Path of repository to use, defaults to the configured repository if there is only one',
help='Path of repository to use, defaults to the configured repository if there is only one, quoted globs supported',
)
mount_group.add_argument('--archive', help='Name of archive to mount (or "latest")')
mount_group.add_argument(
@@ -977,6 +1070,43 @@ def make_parsers():
)
umount_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
rdelete_parser = action_parsers.add_parser(
'rdelete',
aliases=ACTION_ALIASES['rdelete'],
help='Delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
description='Delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
add_help=False,
)
rdelete_group = rdelete_parser.add_argument_group('delete arguments')
rdelete_group.add_argument(
'--repository',
help='Path of repository to delete, defaults to the configured repository if there is only one, quoted globs supported',
)
rdelete_group.add_argument(
'--list',
dest='list_archives',
action='store_true',
help='Show details for the archives in the given repository',
)
rdelete_group.add_argument(
'--force',
action='count',
help='Force deletion of corrupted archives, can be given twice if once does not work',
)
rdelete_group.add_argument(
'--cache-only',
action='store_true',
help='Delete only the local cache for the given repository',
)
rdelete_group.add_argument(
'--keep-security-info',
action='store_true',
help='Do not delete the local security info when deleting a repository',
)
rdelete_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
)
restore_parser = action_parsers.add_parser(
'restore',
aliases=ACTION_ALIASES['restore'],
@@ -987,7 +1117,7 @@ def make_parsers():
restore_group = restore_parser.add_argument_group('restore arguments')
restore_group.add_argument(
'--repository',
help='Path of repository to restore from, defaults to the configured repository if there is only one',
help='Path of repository to restore from, defaults to the configured repository if there is only one, quoted globs supported',
)
restore_group.add_argument(
'--archive', help='Name of archive to restore from (or "latest")', required=True
@@ -1041,7 +1171,7 @@ def make_parsers():
rlist_group = rlist_parser.add_argument_group('rlist arguments')
rlist_group.add_argument(
'--repository',
help='Path of repository to list, defaults to the configured repositories',
help='Path of repository to list, defaults to the configured repositories, quoted globs supported',
)
rlist_group.add_argument(
'--short', default=False, action='store_true', help='Output only archive names'
@@ -1101,7 +1231,7 @@ def make_parsers():
list_group = list_parser.add_argument_group('list arguments')
list_group.add_argument(
'--repository',
help='Path of repository containing archive to list, defaults to the configured repositories',
help='Path of repository containing archive to list, defaults to the configured repositories, quoted globs supported',
)
list_group.add_argument('--archive', help='Name of the archive to list (or "latest")')
list_group.add_argument(
@@ -1168,7 +1298,7 @@ def make_parsers():
rinfo_group = rinfo_parser.add_argument_group('rinfo arguments')
rinfo_group.add_argument(
'--repository',
help='Path of repository to show info for, defaults to the configured repository if there is only one',
help='Path of repository to show info for, defaults to the configured repository if there is only one, quoted globs supported',
)
rinfo_group.add_argument(
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
@@ -1185,7 +1315,7 @@ def make_parsers():
info_group = info_parser.add_argument_group('info arguments')
info_group.add_argument(
'--repository',
help='Path of repository containing archive to show info for, defaults to the configured repository if there is only one',
help='Path of repository containing archive to show info for, defaults to the configured repository if there is only one, quoted globs supported',
)
info_group.add_argument('--archive', help='Name of archive to show info for (or "latest")')
info_group.add_argument(
@@ -1246,7 +1376,7 @@ def make_parsers():
break_lock_group = break_lock_parser.add_argument_group('break-lock arguments')
break_lock_group.add_argument(
'--repository',
help='Path of repository to break the lock for, defaults to the configured repository if there is only one',
help='Path of repository to break the lock for, defaults to the configured repository if there is only one, quoted globs supported',
)
break_lock_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
@@ -1286,7 +1416,7 @@ def make_parsers():
)
key_export_group.add_argument(
'--repository',
help='Path of repository to export the key for, defaults to the configured repository if there is only one',
help='Path of repository to export the key for, defaults to the configured repository if there is only one, quoted globs supported',
)
key_export_group.add_argument(
'--path',
@@ -1307,7 +1437,7 @@ def make_parsers():
borg_group = borg_parser.add_argument_group('borg arguments')
borg_group.add_argument(
'--repository',
help='Path of repository to pass to Borg, defaults to the configured repositories',
help='Path of repository to pass to Borg, defaults to the configured repositories, quoted globs supported',
)
borg_group.add_argument('--archive', help='Name of archive to pass to Borg (or "latest")')
borg_group.add_argument(
+28 -6
View File
@@ -18,6 +18,7 @@ import borgmatic.actions.config.bootstrap
import borgmatic.actions.config.generate
import borgmatic.actions.config.validate
import borgmatic.actions.create
import borgmatic.actions.delete
import borgmatic.actions.export_key
import borgmatic.actions.export_tar
import borgmatic.actions.extract
@@ -26,6 +27,7 @@ import borgmatic.actions.list
import borgmatic.actions.mount
import borgmatic.actions.prune
import borgmatic.actions.rcreate
import borgmatic.actions.rdelete
import borgmatic.actions.restore
import borgmatic.actions.rinfo
import borgmatic.actions.rlist
@@ -479,6 +481,26 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'delete' and action_name not in skip_actions:
borgmatic.actions.delete.run_delete(
repository,
config,
local_borg_version,
action_arguments,
global_arguments,
local_path,
remote_path,
)
elif action_name == 'rdelete' and action_name not in skip_actions:
borgmatic.actions.rdelete.run_rdelete(
repository,
config,
local_borg_version,
action_arguments,
global_arguments,
local_path,
remote_path,
)
elif action_name == 'borg' and action_name not in skip_actions:
borgmatic.actions.borg.run_borg(
repository,
@@ -557,7 +579,7 @@ def load_configurations(config_filenames, overrides=None, resolve_env=True):
)
),
logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=str(error))
),
]
)
@@ -592,13 +614,13 @@ def log_error_records(
level_name = logging._levelToName[levelno]
if not error:
yield log_record(levelno=levelno, levelname=level_name, msg=message)
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
return
try:
raise error
except CalledProcessError as error:
yield log_record(levelno=levelno, levelname=level_name, msg=message)
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
if error.output:
try:
@@ -617,10 +639,10 @@ def log_error_records(
suppress_log=True,
)
yield log_record(levelno=levelno, levelname=level_name, msg=error)
yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
except (ValueError, OSError) as error:
yield log_record(levelno=levelno, levelname=level_name, msg=message)
yield log_record(levelno=levelno, levelname=level_name, msg=error)
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
except: # noqa: E722
# Raising above only as a means of determining the error type. Swallow the exception here
# because we don't want the exception to propagate out of this function.
+140 -47
View File
@@ -521,18 +521,20 @@ properties:
- extract
- disabled
description: |
Name of consistency check to run: "repository",
"archives", "data", "spot", and/or "extract".
"repository" checks the consistency of the
repository, "archives" checks all of the
archives, "data" verifies the integrity of the
data within the archives, "spot" checks that
some percentage of source files are found in the
most recent archive (with identical contents),
and "extract" does an extraction dry-run of the
most recent archive. Note that "data" implies
"archives". See "skip_actions" for disabling
checks altogether.
Name of the consistency check to run:
* "repository" checks the consistency of the
repository.
* "archives" checks all of the archives.
* "data" verifies the integrity of the data
within the archives and implies the "archives"
check as well.
* "spot" checks that some percentage of source
files are found in the most recent archive (with
identical contents).
* "extract" does an extraction dry-run of the
most recent archive.
* See "skip_actions" for disabling checks
altogether.
example: spot
frequency:
type: string
@@ -546,6 +548,20 @@ properties:
"always": running this check every time checks
are run.
example: 2 weeks
only_run_on:
type: array
items:
type: string
description: |
After the "frequency" duration has elapsed, only
run this check if the current day of the week
matches one of these values (the name of a day of
the week in the current locale). "weekday" and
"weekend" are also accepted. Defaults to running
the check on any day of the week.
example:
- Saturday
- Sunday
- required: [name]
additionalProperties: false
properties:
@@ -554,18 +570,20 @@ properties:
enum:
- repository
description: |
Name of consistency check to run: "repository",
"archives", "data", "spot", and/or "extract".
"repository" checks the consistency of the
repository, "archives" checks all of the
archives, "data" verifies the integrity of the
data within the archives, "spot" checks that
some percentage of source files are found in the
most recent archive (with identical contents),
and "extract" does an extraction dry-run of the
most recent archive. Note that "data" implies
"archives". See "skip_actions" for disabling
checks altogether.
Name of the consistency check to run:
* "repository" checks the consistency of the
repository.
* "archives" checks all of the archives.
* "data" verifies the integrity of the data
within the archives and implies the "archives"
check as well.
* "spot" checks that some percentage of source
files are found in the most recent archive (with
identical contents).
* "extract" does an extraction dry-run of the
most recent archive.
* See "skip_actions" for disabling checks
altogether.
example: spot
frequency:
type: string
@@ -579,6 +597,20 @@ properties:
"always": running this check every time checks
are run.
example: 2 weeks
only_run_on:
type: array
items:
type: string
description: |
After the "frequency" duration has elapsed, only
run this check if the current day of the week
matches one of these values (the name of a day of
the week in the current locale). "weekday" and
"weekend" are also accepted. Defaults to running
the check on any day of the week.
example:
- Saturday
- Sunday
max_duration:
type: integer
description: |
@@ -602,18 +634,20 @@ properties:
enum:
- spot
description: |
Name of consistency check to run: "repository",
"archives", "data", "spot", and/or "extract".
"repository" checks the consistency of the
repository, "archives" checks all of the
archives, "data" verifies the integrity of the
data within the archives, "spot" checks that
some percentage of source files are found in the
most recent archive (with identical contents),
and "extract" does an extraction dry-run of the
most recent archive. Note that "data" implies
"archives". See "skip_actions" for disabling
checks altogether.
Name of the consistency check to run:
* "repository" checks the consistency of the
repository.
* "archives" checks all of the archives.
* "data" verifies the integrity of the data
within the archives and implies the "archives"
check as well.
* "spot" checks that some percentage of source
files are found in the most recent archive (with
identical contents).
* "extract" does an extraction dry-run of the
most recent archive.
* See "skip_actions" for disabling checks
altogether.
example: repository
frequency:
type: string
@@ -627,6 +661,20 @@ properties:
"always": running this check every time checks
are run.
example: 2 weeks
only_run_on:
type: array
items:
type: string
description: |
After the "frequency" duration has elapsed, only
run this check if the current day of the week
matches one of these values (the name of a day of
the week in the current locale). "weekday" and
"weekend" are also accepted. Defaults to running
the check on any day of the week.
example:
- Saturday
- Sunday
count_tolerance_percentage:
type: number
description: |
@@ -635,7 +683,8 @@ properties:
archive file count that is allowed before the
entire consistency check fails. This can catch
problems like incorrect excludes, inadvertent
deletes, etc. Only applies to the "spot" check.
deletes, etc. Required (and only valid) for the
"spot" check.
example: 10
data_sample_percentage:
type: number
@@ -643,7 +692,8 @@ properties:
The percentage of total files in the source
directories to randomly sample and compare to
their corresponding files in the most recent
backup archive. Only applies to the "spot" check.
backup archive. Required (and only valid) for the
"spot" check.
example: 1
data_tolerance_percentage:
type: number
@@ -655,7 +705,7 @@ properties:
been bulk-changed by malware, backups that have
been tampered with, etc. The value must be lower
than or equal to the "contents_sample_percentage".
Only applies to the "spot" check.
Required (and only valid) for the "spot" check.
example: 0.5
xxh64sum_command:
type: string
@@ -664,7 +714,7 @@ properties:
source files, usually found in an OS package named
"xxhash". Do not substitute with a different hash
type (SHA, MD5, etc.) or the check will never
succeed. Only applies to the "spot" check.
succeed. Only valid for the "spot" check.
example: /usr/local/bin/xxh64sum
description: |
List of one or more consistency checks to run on a periodic basis
@@ -705,11 +755,13 @@ properties:
- compact
- create
- check
- delete
- extract
- config
- export-tar
- mount
- umount
- rdelete
- restore
- rlist
- list
@@ -992,9 +1044,12 @@ properties:
description: |
Command to use instead of "pg_dump" or "pg_dumpall".
This can be used to run a specific pg_dump version
(e.g., one inside a running container). Defaults to
"pg_dump" for single database dump or "pg_dumpall" to
dump all databases.
(e.g., one inside a running container). If you run it
from within a container, make sure to mount your
host's ".borgmatic" folder into the container using
the same directory structure. Defaults to "pg_dump"
for single database dump or "pg_dumpall" to dump all
databases.
example: docker exec my_pg_container pg_dump
pg_restore_command:
type: string
@@ -1108,7 +1163,10 @@ properties:
description: |
Command to use instead of "mariadb-dump". This can be
used to run a specific mariadb_dump version (e.g., one
inside a running container). Defaults to "mariadb-dump".
inside a running container). If you run it from within
a container, make sure to mount your host's
".borgmatic" folder into the container using the same
directory structure. Defaults to "mariadb-dump".
example: docker exec mariadb_container mariadb-dump
mariadb_command:
type: string
@@ -1236,9 +1294,12 @@ properties:
mysql_dump_command:
type: string
description: |
Command to use instead of "mysqldump". This can be used
to run a specific mysql_dump version (e.g., one inside a
running container). Defaults to "mysqldump".
Command to use instead of "mysqldump". This can be
used to run a specific mysql_dump version (e.g., one
inside a running container). If you run it from within
a container, make sure to mount your host's
".borgmatic" folder into the container using the same
directory structure. Defaults to "mysqldump".
example: docker exec mysql_container mysqldump
mysql_command:
type: string
@@ -1724,6 +1785,38 @@ properties:
an account at https://healthchecks.io (or self-host Healthchecks) if
you'd like to use this service. See borgmatic monitoring
documentation for details.
uptime_kuma:
type: object
required: ['push_url']
additionalProperties: false
properties:
push_url:
type: string
description: |
Uptime Kuma push URL without query string (do not include the
question mark or anything after it).
example: https://example.uptime.kuma/api/push/abcd1234
states:
type: array
items:
type: string
enum:
- start
- finish
- fail
uniqueItems: true
description: |
List of one or more monitoring states to push for: "start",
"finish", and/or "fail". Defaults to pushing for all
states.
example:
- start
- finish
- fail
description: |
Configuration for a monitoring integration with Uptime Kuma using
the Push monitor type.
See more information here: https://uptime.kuma.pet
cronitor:
type: object
required: ['ping_url']
+18 -5
View File
@@ -1,3 +1,4 @@
import fnmatch
import os
import jsonschema
@@ -149,18 +150,30 @@ def normalize_repository_path(repository):
return repository
def glob_match(first, second):
'''
Given two strings, return whether the first matches the second. Globs are
supported.
'''
if first is None or second is None:
return False
return fnmatch.fnmatch(first, second) or fnmatch.fnmatch(second, first)
def repositories_match(first, second):
'''
Given two repository dicts with keys 'path' (relative and/or absolute),
and 'label', or two repository paths, return whether they match.
Given two repository dicts with keys "path" (relative and/or absolute),
and "label", two repository paths as strings, or a mix of the two formats,
return whether they match. Globs are supported.
'''
if isinstance(first, str):
first = {'path': first, 'label': first}
if isinstance(second, str):
second = {'path': second, 'label': second}
return (first.get('label') == second.get('label')) or (
normalize_repository_path(first.get('path'))
== normalize_repository_path(second.get('path'))
return glob_match(first.get('label'), second.get('label')) or glob_match(
normalize_repository_path(first.get('path')), normalize_repository_path(second.get('path'))
)
+3 -1
View File
@@ -13,6 +13,7 @@ from borgmatic.hooks import (
pagerduty,
postgresql,
sqlite,
uptimekuma,
)
logger = logging.getLogger(__name__)
@@ -22,6 +23,7 @@ HOOK_NAME_TO_MODULE = {
'cronhub': cronhub,
'cronitor': cronitor,
'healthchecks': healthchecks,
'loki': loki,
'mariadb_databases': mariadb,
'mongodb_databases': mongodb,
'mysql_databases': mysql,
@@ -29,7 +31,7 @@ HOOK_NAME_TO_MODULE = {
'pagerduty': pagerduty,
'postgresql_databases': postgresql,
'sqlite_databases': sqlite,
'loki': loki,
'uptime_kuma': uptimekuma,
}
+1 -1
View File
@@ -15,7 +15,7 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
monitor.State.LOG: 'log',
}
DEFAULT_PING_BODY_LIMIT_BYTES = 1500
DEFAULT_PING_BODY_LIMIT_BYTES = 100000
HANDLER_IDENTIFIER = 'healthchecks'
+10 -1
View File
@@ -1,6 +1,15 @@
from enum import Enum
MONITOR_HOOK_NAMES = ('apprise', 'healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki')
MONITOR_HOOK_NAMES = (
'apprise',
'cronhub',
'cronitor',
'healthchecks',
'loki',
'ntfy',
'pagerduty',
'uptime_kuma',
)
class State(Enum):
+1 -1
View File
@@ -40,7 +40,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
return
hostname = platform.node()
local_timestamp = datetime.datetime.now(datetime.UTC).astimezone().isoformat()
local_timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat()
payload = json.dumps(
{
'routing_key': hook_config['integration_key'],
+55
View File
@@ -0,0 +1,55 @@
import logging
import requests
logger = logging.getLogger(__name__)
def initialize_monitor(
push_url, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No initialization is necessary for this monitor.
'''
pass
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
'''
Make a get request to the configured Uptime Kuma push_url. Use the given configuration filename
in any log entries. If this is a dry run, then don't actually push anything.
'''
run_states = hook_config.get('states', ['start', 'finish', 'fail'])
if state.name.lower() not in run_states:
return
dry_run_label = ' (dry run; not actually pushing)' if dry_run else ''
status = 'down' if state.name.lower() == 'fail' else 'up'
push_url = hook_config.get('push_url', 'https://example.uptime.kuma/api/push/abcd1234')
query = f'status={status}&msg={state.name.lower()}'
logger.info(
f'{config_filename}: Pushing Uptime Kuma push_url {push_url}?{query} {dry_run_label}'
)
logger.debug(f'{config_filename}: Full Uptime Kuma state URL {push_url}?{query}')
if dry_run:
return
logging.getLogger('urllib3').setLevel(logging.ERROR)
try:
response = requests.get(f'{push_url}?{query}')
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'{config_filename}: Uptime Kuma error: {error}')
def destroy_monitor(
push_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''
pass
+1 -1
View File
@@ -38,7 +38,7 @@ def should_do_markup(no_color, configs):
if no_color:
return False
if any(config.get('output', {}).get('color') is False for config in configs.values()):
if any(config.get('color', True) is False for config in configs.values()):
return False
if os.environ.get('NO_COLOR', None):
+7 -5
View File
@@ -1,14 +1,15 @@
FROM docker.io/alpine:3.17.1 as borgmatic
FROM docker.io/alpine:3.20.1 AS borgmatic
COPY . /app
RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib
RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
RUN pip install --break-system-packages --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
RUN borgmatic --help > /command-line.txt \
&& for action in rcreate transfer create prune compact check extract config "config bootstrap" "config generate" "config validate" export-tar mount umount restore rlist list rinfo info break-lock borg; do \
&& for action in rcreate transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" export-tar mount umount rdelete restore rlist list rinfo info break-lock borg; do \
echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
&& borgmatic $action --help >> /command-line.txt; done
RUN /app/docs/fetch-contributors >> /contributors.html
FROM docker.io/node:19.5.0-alpine as html
FROM docker.io/node:22.4.0-alpine AS html
ARG ENVIRONMENT=production
@@ -24,11 +25,12 @@ RUN npm install @11ty/eleventy \
markdown-it-replace-link
COPY --from=borgmatic /etc/borgmatic/config.yaml /source/docs/_includes/borgmatic/config.yaml
COPY --from=borgmatic /command-line.txt /source/docs/_includes/borgmatic/command-line.txt
COPY --from=borgmatic /contributors.html /source/docs/_includes/borgmatic/contributors.html
COPY . /source
RUN NODE_ENV=${ENVIRONMENT} npx eleventy --input=/source/docs --output=/output/docs \
&& mv /output/docs/index.html /output/index.html
FROM docker.io/nginx:1.22.1-alpine
FROM docker.io/nginx:1.26.1-alpine
COPY --from=html /output /usr/share/nginx/html
COPY --from=borgmatic /etc/borgmatic/config.yaml /usr/share/nginx/html/docs/reference/config.yaml
+4 -5
View File
@@ -167,12 +167,11 @@ li {
padding: .25em 0;
}
li ul {
margin: .5em 0;
padding-left: 1em;
list-style-type: disc;
padding-left: 2em;
}
li li {
padding-top: .1em;
padding-bottom: .1em;
li li:last-child {
padding-bottom: 0em;
}
/* Syntax highlighting and Code blocks */
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/python
'''
A script to fetch recent contributors to borgmatic, used during documentation generation.
'''
import datetime
import itertools
import operator
import subprocess
import requests
def list_merged_pulls(url):
'''
Given a Gitea 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'})
if not response.ok:
response.raise_for_status()
return tuple(pull for pull in response.json() if pull.get('merged_at'))
API_ENDPOINT_URLS = (
'https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/pulls',
'https://api.github.com/repos/borgmatic-collective/borgmatic/pulls',
)
RECENT_CONTRIBUTORS_CUTOFF_DAYS = 365
def print_contributors():
'''
Display the recent contributors as a row of avatars in an HTML fragment.
'''
pulls = tuple(itertools.chain.from_iterable(list_merged_pulls(url) for url in API_ENDPOINT_URLS))
seen_user_ids = set()
print('<p>')
for pull in sorted(pulls, key=operator.itemgetter('merged_at'), reverse=True):
merged_at = pull.get('merged_at')
user = pull.get('user')
if not merged_at or not user:
continue
user_id = user.get('id')
if not user_id or user_id in seen_user_ids:
continue
if datetime.datetime.fromisoformat(merged_at) < datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=RECENT_CONTRIBUTORS_CUTOFF_DAYS):
continue
seen_user_ids.add(user_id)
print(
f'''<a href="{user.get('html_url')}?tab=activity"><img src="{user.get('avatar_url')}" width="50" height="50" title="{user.get('full_name') or user.get('login')}" /></a>'''
)
print('</p>')
if __name__ == '__main__':
print_contributors()
+14 -1
View File
@@ -229,7 +229,20 @@ hooks:
... where `my_pg_container` is the name of your database container. In this
example, you'd also need to set the `pg_restore_command` and `psql_command`
options.
options. If you choose to use the `pg_dump` command within the container
though, note that it will output the database dump to a file inside the
container. So you'll have to mount the `.borgmatic` folder from your host's
home folder into the container using the same directory structure.
See the following Docker compose file an as example:
```yaml
services:
db:
image: postgres
volumes:
- /home/USERNAME/.borgmatic:/home/USERNAME/.borgmatic
```
Similar command override options are available for (some of) the other
supported database types as well. See the [configuration
+1 -1
View File
@@ -48,7 +48,7 @@ support for [more granular exit
codes](https://borgbackup.readthedocs.io/en/1.4-maint/usage/general.html#return-codes)
means that you can configure borgmatic to respond to specific Borg conditions.
See the full list of [Borg 1.4 error and warning exit
codes](https://borgbackup.readthedocs.io/en/1.4.0b1/internals/frontends.html#message-ids).
codes](https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#message-ids).
The `rc:` numeric value there tells you the exit code for each.
For instance, this borgmatic configuration elevates all Borg backup file
@@ -242,6 +242,57 @@ check --force` runs `check` even if it's specified in the `skip_actions`
option.
### Check days
<span class="minilink minilink-addedin">New in version 1.8.13</span> You can
optionally configure checks to only run on particular days of the week. For
instance:
```yaml
checks:
- name: repository
only_run_on:
- Saturday
- Sunday
- name: archives
only_run_on:
- weekday
- name: spot
only_run_on:
- Friday
- weekend
```
Each day of the week is specified in the current locale (system
language/country settings). `weekend` and `weekday` are also accepted.
Just like with `frequency`, borgmatic only makes a best effort to run checks
on the given day of the week. For instance, if you run `borgmatic check`
daily, then every day borgmatic will have an opportunity to determine whether
your checks are configured to run on that day. If they are, then the checks
run. If not, they are skipped.
For instance, with the above configuration, if borgmatic is run on a Saturday,
the `repository` check will run. But on a Monday? The repository check will
get skipped. And if borgmatic is never run on a Saturday or a Sunday, that
check will never get a chance to run.
Also, the day of the week configuration applies *after* any configured
`frequency` for a check. So for instance, imagine the following configuration:
```yaml
checks:
- name: repository
frequency: 2 weeks
only_run_on:
- Monday
```
If you run borgmatic daily with that configuration, then borgmatic will first
wait two weeks after the previous check before running the check again—on the
first Monday after the `frequency` duration elapses.
### Running only checks
<span class="minilink minilink-addedin">New in version 1.7.1</span> If you
+15 -6
View File
@@ -102,9 +102,9 @@ and depend on containers for runtime dependencies. These tests do run on the
continuous integration (CI) server, and running them on your developer machine
is the closest thing to dev-CI parity.
If you would like to run the full test suite, first install Docker (or Podman;
see below) and [Docker Compose](https://docs.docker.com/compose/install/).
Then run:
If you would like to run the end-to-end tests, first install Docker (or
Podman; see below) and [Docker
Compose](https://docs.docker.com/compose/install/). Then run:
```bash
scripts/run-end-to-end-tests
@@ -152,12 +152,21 @@ the following deviations from it:
* In general, spell out words in variable names instead of shortening them.
So, think `index` instead of `idx`. There are some notable exceptions to
this though (like `config`).
* Favor blank lines around logical code groupings, `if` statements,
`return`s, etc. Readability is more important than packing code tightly.
* Import fully qualified Python modules instead of importing individual
functions, classes, or constants. E.g., do `import os.path` instead of
`from os import path`. (Some exceptions to this are made in tests.)
* Only use classes and OOP as a last resort, such as when integrating with
Python libraries that require it.
* Prefer functional code where it makes sense, e.g. when constructing a
command (to subsequently execute imperatively).
borgmatic code uses the [Black](https://black.readthedocs.io/en/stable/) code
borgmatic uses the [Black](https://black.readthedocs.io/en/stable/) code
formatter, the [Flake8](http://flake8.pycqa.org/en/latest/) code checker, and
the [isort](https://github.com/timothycrosley/isort) import orderer, so
certain code style requirements will be enforced when running automated tests.
See the Black, Flake8, and isort documentation for more information.
certain code style requirements are enforced when running automated tests. See
the Black, Flake8, and isort documentation for more information.
## Continuous integration
+1 -1
View File
@@ -133,7 +133,7 @@ For Borg 1.x, use a shell pattern for the `match_archives` value and see the
[Borg patterns
documentation](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns)
for more information. For Borg 2.x, see the [match archives
documentation](https://borgbackup.readthedocs.io/en/2.0.0b5/usage/help.html#borg-help-match-archives).
documentation](https://borgbackup.readthedocs.io/en/2.0.0b8/usage/help.html#borg-help-match-archives).
Some borgmatic command-line actions also have a `--match-archives` flag that
overrides both the auto-matching behavior and the `match_archives`
+63 -6
View File
@@ -39,13 +39,14 @@ below for how to configure this.
borgmatic integrates with these monitoring services and libraries, pinging
them as backups happen:
* [Healthchecks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook)
* [Cronitor](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook)
* [Cronhub](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook)
* [PagerDuty](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
* [ntfy](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook)
* [Grafana Loki](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook)
* [Apprise](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook)
* [Cronhub](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook)
* [Cronitor](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook)
* [Grafana Loki](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook)
* [Healthchecks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook)
* [ntfy](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook)
* [PagerDuty](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
* [Uptime Kuma](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptime-kuma-hook)
The idea is that you'll receive an alert when something goes wrong or when the
service doesn't hear from borgmatic for a configured interval (if supported).
@@ -505,6 +506,62 @@ See the [configuration
reference](https://torsion.org/borgmatic/docs/reference/configuration/) for
details.
## Uptime Kuma hook
<span class="minilink minilink-addedin">New in version 1.8.13</span> [Uptime
Kuma](https://uptime.kuma.pet) is an easy-to-use, self-hosted monitoring tool
and can provide a Push monitor type to accept HTTP `GET` requests from a
service instead of contacting it directly.
Uptime Kuma allows you to see a history of monitor states and can in turn
alert via ntfy, Gotify, Matrix, Apprise, Email, and many more.
An example configuration is shown here with all the available options:
```yaml
uptime_kuma:
push_url: https://kuma.my-domain.com/api/push/abcd1234
states:
- start
- finish
- fail
```
The `push_url` is provided to your from your Uptime Kuma service and
originally includes a query string—the text including and after the question
mark (`?`). But please do not include the query string in the `push_url`
configuration; borgmatic will add this automatically depending on the state of
your backup.
Using `start`, `finish` and `fail` states means you will get two "up beats" in
Uptime Kuma for successful backups and the ability to see failures if and when
the backup started (was there a `start` beat?).
A reasonable base-level configuration for an Uptime Kuma Monitor for a backup
is below:
```ini
# These are to be entered into Uptime Kuma and not into your borgmatic
# configuration.
# Push monitors wait for the client to contact Uptime Kuma instead of Uptime
# Kuma contacting the client. This is perfect for backup monitoring.
Monitor Type = Push
Heartbeat Interval = 90000 # = 25 hours = 1 day + 1 hour
# Wait 6 times the Heartbeat Retry (below) before logging a heartbeat missed.
Retries = 6
# Multiplied by Retries this gives a grace period within which the monitor
# goes into the "Pending" state.
Heartbeat Retry = 360 # = 10 minutes
# For each Heartbeat Interval if the backup fails repeatedly, a notification
# is sent each time.
Resend Notification every X times = 1
```
## Scripting borgmatic
+2 -2
View File
@@ -151,7 +151,7 @@ borgmatic rcreate --verbosity 1 --encryption repokey-aes-ocb \
```
Read about [Borg encryption
modes](https://borgbackup.readthedocs.io/en/2.0.0b5/usage/rcreate.html#encryption-mode-tldr)
modes](https://borgbackup.readthedocs.io/en/2.0.0b8/usage/rcreate.html#encryption-mode-tldr)
for more details.
To transfer data from your original Borg 1 repository to your newly created
@@ -174,7 +174,7 @@ confirmation of success—or tells you if something hasn't been transferred yet.
Note that by omitting the `--upgrader` flag, you can also do archive transfers
between related Borg 2 repositories without upgrading, even down to individual
archives. For more on that functionality, see the [Borg transfer
documentation](https://borgbackup.readthedocs.io/en/2.0.0b5/usage/transfer.html).
documentation](https://borgbackup.readthedocs.io/en/2.0.0b8/usage/transfer.html).
That's it! Now you can use your new Borg 2 repository as normal with
borgmatic. If you've got multiple repositories, repeat the above process for
+7 -10
View File
@@ -10,20 +10,17 @@ eleventyNavigation:
If case you're interested in [developing on
borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/),
here's an abridged primer on how its Python source code is organized to help
you get started. At the top level we have:
you get started. Starting at the top level, we have:
* [borgmatic](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic): The main borgmatic source module. Most of the code is here.
* [borgmatic](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic): The main borgmatic source module. Most of the code is here. Within that:
* [actions](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/actions): borgmatic-specific logic for running each action (create, list, check, etc.).
* [borg](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/borg): Lower-level code that's responsible for interacting with Borg to run each action.
* [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion.
* [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration.
* [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database or monitoring integration? Start here.
* [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now.
* [sample](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/sample): Example configurations for cron and systemd.
* [scripts](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/scripts): Dev-facing scripts for things like building documentation and running end-to-end tests.
* [tests](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/tests): Automated tests organized by: end-to-end, integration, and unit.
Within the `borgmatic` directory you'll find:
* [actions](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/actions): Mid-level code for running each borgmatic action (create, list, check, etc.).
* [borg](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/borg): Lower-level code that actually shells out to Borg for each action.
* [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion.
* [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration.
* [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database or monitoring integration? Start here.
So, broadly speaking, the control flow goes: `commands``config` followed by `commands``actions``borg` and `hooks`.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
@@ -21,7 +21,7 @@ apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client m
py3-ruamel.yaml py3-ruamel.yaml.clib py3-yaml bash sqlite fish
# If certain dependencies of black are available in this version of Alpine, install them.
apk add --no-cache py3-typed-ast py3-regex || true
python3 -m pip install --no-cache --upgrade pip==22.2.2 setuptools==64.0.1
python3 -m pip install --no-cache --upgrade pip==24.2 setuptools==72.1.0
pip3 install --ignore-installed tox==4.11.3
export COVERAGE_FILE=/tmp/.coverage
+1 -1
View File
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.8.12'
VERSION = '1.8.14'
setup(
+1 -1
View File
@@ -2,7 +2,7 @@ appdirs==1.4.4
apprise==1.8.0
attrs==23.2.0
black==24.4.2
certifi==2024.2.2
certifi==2024.7.4
chardet==5.2.0
click==8.1.7
codespell==2.2.6
+3 -3
View File
@@ -9,7 +9,7 @@ def test_validate_config_command_with_valid_configuration_succeeds():
config_path = os.path.join(temporary_directory, 'test.yaml')
subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
exit_code = subprocess.call(f'validate-borgmatic-config --config {config_path}'.split(' '))
exit_code = subprocess.call(f'borgmatic config validate --config {config_path}'.split(' '))
assert exit_code == 0
@@ -24,7 +24,7 @@ def test_validate_config_command_with_invalid_configuration_fails():
config_file.write(config)
config_file.close()
exit_code = subprocess.call(f'validate-borgmatic-config --config {config_path}'.split(' '))
exit_code = subprocess.call(f'borgmatic config validate --config {config_path}'.split(' '))
assert exit_code == 1
@@ -35,7 +35,7 @@ def test_validate_config_command_with_show_flag_displays_configuration():
subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
output = subprocess.check_output(
f'validate-borgmatic-config --config {config_path} --show'.split(' ')
f'borgmatic config validate --config {config_path} --show'.split(' ')
).decode(sys.stdout.encoding)
assert 'repositories:' in output
+69 -1
View File
@@ -113,6 +113,74 @@ def test_filter_checks_on_frequency_retains_check_without_frequency():
) == ('archives',)
def test_filter_checks_on_frequency_retains_check_with_empty_only_run_on():
flexmock(module).should_receive('parse_frequency').and_return(None)
assert module.filter_checks_on_frequency(
config={'checks': [{'name': 'archives', 'only_run_on': []}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
archives_check_id='1234',
datetime_now=flexmock(weekday=lambda: 0),
) == ('archives',)
def test_filter_checks_on_frequency_retains_check_with_only_run_on_matching_today():
flexmock(module).should_receive('parse_frequency').and_return(None)
assert module.filter_checks_on_frequency(
config={'checks': [{'name': 'archives', 'only_run_on': [module.calendar.day_name[0]]}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
archives_check_id='1234',
datetime_now=flexmock(weekday=lambda: 0),
) == ('archives',)
def test_filter_checks_on_frequency_retains_check_with_only_run_on_matching_today_via_weekday_value():
flexmock(module).should_receive('parse_frequency').and_return(None)
assert module.filter_checks_on_frequency(
config={'checks': [{'name': 'archives', 'only_run_on': ['weekday']}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
archives_check_id='1234',
datetime_now=flexmock(weekday=lambda: 0),
) == ('archives',)
def test_filter_checks_on_frequency_retains_check_with_only_run_on_matching_today_via_weekend_value():
flexmock(module).should_receive('parse_frequency').and_return(None)
assert module.filter_checks_on_frequency(
config={'checks': [{'name': 'archives', 'only_run_on': ['weekend']}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
archives_check_id='1234',
datetime_now=flexmock(weekday=lambda: 6),
) == ('archives',)
def test_filter_checks_on_frequency_skips_check_with_only_run_on_not_matching_today():
flexmock(module).should_receive('parse_frequency').and_return(None)
assert (
module.filter_checks_on_frequency(
config={'checks': [{'name': 'archives', 'only_run_on': [module.calendar.day_name[5]]}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
archives_check_id='1234',
datetime_now=flexmock(weekday=lambda: 0),
)
== ()
)
def test_filter_checks_on_frequency_retains_check_with_elapsed_frequency():
flexmock(module).should_receive('parse_frequency').and_return(
module.datetime.timedelta(hours=1)
@@ -168,7 +236,7 @@ def test_filter_checks_on_frequency_skips_check_with_unelapsed_frequency():
)
def test_filter_checks_on_frequency_restains_check_with_unelapsed_frequency_and_force():
def test_filter_checks_on_frequency_retains_check_with_unelapsed_frequency_and_force():
assert module.filter_checks_on_frequency(
config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
+43
View File
@@ -0,0 +1,43 @@
from flexmock import flexmock
from borgmatic.actions import delete as module
def test_run_delete_does_not_raise():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name')
flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return(
flexmock()
)
flexmock(module.borgmatic.borg.delete).should_receive('delete_archives')
module.run_delete(
repository={'path': 'repo'},
config={},
local_borg_version=None,
delete_arguments=flexmock(repository=flexmock(), archive=flexmock()),
global_arguments=flexmock(),
local_path=None,
remote_path=None,
)
def test_run_delete_without_archive_does_not_raise():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name')
flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return(
flexmock()
)
flexmock(module.borgmatic.borg.delete).should_receive('delete_archives')
module.run_delete(
repository={'path': 'repo'},
config={},
local_borg_version=None,
delete_arguments=flexmock(repository=flexmock(), archive=None),
global_arguments=flexmock(),
local_path=None,
remote_path=None,
)
+41
View File
@@ -0,0 +1,41 @@
from flexmock import flexmock
from borgmatic.actions import rdelete as module
def test_run_rdelete_does_not_raise():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return(
flexmock()
)
flexmock(module.borgmatic.borg.rdelete).should_receive('delete_repository')
module.run_rdelete(
repository={'path': 'repo'},
config={},
local_borg_version=None,
rdelete_arguments=flexmock(repository=flexmock(), cache_only=False),
global_arguments=flexmock(),
local_path=None,
remote_path=None,
)
def test_run_rdelete_with_cache_only_does_not_raise():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return(
flexmock()
)
flexmock(module.borgmatic.borg.rdelete).should_receive('delete_repository')
module.run_rdelete(
repository={'path': 'repo'},
config={},
local_borg_version=None,
rdelete_arguments=flexmock(repository=flexmock(), cache_only=True),
global_arguments=flexmock(),
local_path=None,
remote_path=None,
)
+15
View File
@@ -1891,3 +1891,18 @@ def test_check_all_source_directories_exist_with_non_existent_directory_raises()
with pytest.raises(ValueError):
module.check_all_source_directories_exist(['foo'])
def test_check_all_source_directories_exist_with_working_directory_applies_to_relative_source_directories():
flexmock(module).should_receive('expand_directory').with_args('/tmp/foo*').and_return(
('/tmp/foo', '/tmp/food')
)
flexmock(module).should_receive('expand_directory').with_args('/root/bar').and_return(
('/root/bar',)
)
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/tmp/foo').and_return(True)
flexmock(module.os.path).should_receive('exists').with_args('/tmp/food').and_return(True)
flexmock(module.os.path).should_receive('exists').with_args('/root/bar').and_return(True)
module.check_all_source_directories_exist(['foo*', '/root/bar'], working_directory='/tmp')
+338
View File
@@ -0,0 +1,338 @@
import logging
import pytest
from flexmock import flexmock
from borgmatic.borg import delete as module
from ..test_verbosity import insert_logging_mock
def test_make_delete_command_includes_log_info():
insert_logging_mock(logging.INFO)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--info', 'repo')
def test_make_delete_command_includes_log_debug():
insert_logging_mock(logging.DEBUG)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--debug', '--show-rc', 'repo')
def test_make_delete_command_includes_dry_run():
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'dry-run', True
).and_return(('--dry-run',))
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None),
global_arguments=flexmock(dry_run=True, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--dry-run', 'repo')
def test_make_delete_command_includes_remote_path():
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'remote-path', 'borg1'
).and_return(('--remote-path', 'borg1'))
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path='borg1',
)
assert command == ('borg', 'delete', '--remote-path', 'borg1', 'repo')
def test_make_delete_command_includes_log_json():
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'log-json', True
).and_return(('--log-json',))
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None),
global_arguments=flexmock(dry_run=False, log_json=True),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--log-json', 'repo')
def test_make_delete_command_includes_lock_wait():
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'lock-wait', 5
).and_return(('--lock-wait', '5'))
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={'lock_wait': 5},
local_borg_version='1.2.3',
delete_arguments=flexmock(list_archives=False, force=0, match_archives=None, archive=None),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--lock-wait', '5', 'repo')
def test_make_delete_command_includes_list():
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'list', True
).and_return(('--list',))
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(list_archives=True, force=0, match_archives=None, archive=None),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--list', 'repo')
def test_make_delete_command_includes_force():
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(list_archives=False, force=1, match_archives=None, archive=None),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--force', 'repo')
def test_make_delete_command_includes_force_twice():
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(list_archives=False, force=2, match_archives=None, archive=None),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--force', '--force', 'repo')
def test_make_delete_command_includes_archive():
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(
('--match-archives', 'archive')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(
list_archives=False, force=0, match_archives=None, archive='archive'
),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--match-archives', 'archive', 'repo')
def test_make_delete_command_includes_match_archives():
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(
('--match-archives', 'sh:foo*')
)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_delete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
delete_arguments=flexmock(
list_archives=False, force=0, match_archives='sh:foo*', archive='archive'
),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', '--match-archives', 'sh:foo*', 'repo')
def test_delete_archives_with_archive_calls_borg_delete():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.rdelete).should_receive('delete_repository').never()
flexmock(module).should_receive('make_delete_command').and_return(flexmock())
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock()
)
flexmock(module.borgmatic.execute).should_receive('execute_command').once()
module.delete_archives(
repository={'path': 'repo'},
config={},
local_borg_version=flexmock(),
delete_arguments=flexmock(archive='archive'),
global_arguments=flexmock(),
)
def test_delete_archives_with_match_archives_calls_borg_delete():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.rdelete).should_receive('delete_repository').never()
flexmock(module).should_receive('make_delete_command').and_return(flexmock())
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock()
)
flexmock(module.borgmatic.execute).should_receive('execute_command').once()
module.delete_archives(
repository={'path': 'repo'},
config={},
local_borg_version=flexmock(),
delete_arguments=flexmock(match_archives='sh:foo*'),
global_arguments=flexmock(),
)
@pytest.mark.parametrize('argument_name', module.ARCHIVE_RELATED_ARGUMENT_NAMES[2:])
def test_delete_archives_with_archive_related_argument_calls_borg_delete(argument_name):
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.rdelete).should_receive('delete_repository').never()
flexmock(module).should_receive('make_delete_command').and_return(flexmock())
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock()
)
flexmock(module.borgmatic.execute).should_receive('execute_command').once()
module.delete_archives(
repository={'path': 'repo'},
config={},
local_borg_version=flexmock(),
delete_arguments=flexmock(archive='archive', **{argument_name: 'value'}),
global_arguments=flexmock(),
)
def test_delete_archives_without_archive_related_argument_calls_borg_rdelete():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.rdelete).should_receive('delete_repository').once()
flexmock(module).should_receive('make_delete_command').never()
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').never()
flexmock(module.borgmatic.execute).should_receive('execute_command').never()
module.delete_archives(
repository={'path': 'repo'},
config={},
local_borg_version=flexmock(),
delete_arguments=flexmock(
list_archives=True, force=False, cache_only=False, keep_security_info=False
),
global_arguments=flexmock(),
)
+14
View File
@@ -190,6 +190,20 @@ def test_make_match_archives_flags_makes_flags_with_globs(
)
def test_make_match_archives_flags_accepts_default_archive_name_format():
flexmock(module.feature).should_receive('available').and_return(True)
assert (
module.make_match_archives_flags(
match_archives=None,
archive_name_format=None,
local_borg_version=flexmock(),
default_archive_name_format='*',
)
== ()
)
def test_warn_for_aggressive_archive_flags_without_archive_flags_bails():
flexmock(module.logger).should_receive('warning').never()
+336
View File
@@ -0,0 +1,336 @@
import logging
from flexmock import flexmock
from borgmatic.borg import rdelete as module
from ..test_verbosity import insert_logging_mock
def test_make_rdelete_command_with_feature_available_runs_borg_rdelete():
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=0),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rdelete', 'repo')
def test_make_rdelete_command_without_feature_available_runs_borg_delete():
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(False)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=0),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'delete', 'repo')
def test_make_rdelete_command_includes_log_info():
insert_logging_mock(logging.INFO)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=0),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rdelete', '--info', 'repo')
def test_make_rdelete_command_includes_log_debug():
insert_logging_mock(logging.DEBUG)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=0),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rdelete', '--debug', '--show-rc', 'repo')
def test_make_rdelete_command_includes_dry_run():
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'dry-run', True
).and_return(('--dry-run',))
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=0),
global_arguments=flexmock(dry_run=True, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rdelete', '--dry-run', 'repo')
def test_make_rdelete_command_includes_remote_path():
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'remote-path', 'borg1'
).and_return(('--remote-path', 'borg1'))
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=0),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path='borg1',
)
assert command == ('borg', 'rdelete', '--remote-path', 'borg1', 'repo')
def test_make_rdelete_command_includes_log_json():
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'log-json', True
).and_return(('--log-json',))
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=0),
global_arguments=flexmock(dry_run=False, log_json=True),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rdelete', '--log-json', 'repo')
def test_make_rdelete_command_includes_lock_wait():
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'lock-wait', 5
).and_return(('--lock-wait', '5'))
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={'lock_wait': 5},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=0),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rdelete', '--lock-wait', '5', 'repo')
def test_make_rdelete_command_includes_list():
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').with_args(
'list', True
).and_return(('--list',))
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=True, force=0),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rdelete', '--list', 'repo')
def test_make_rdelete_command_includes_force():
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=1),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rdelete', '--force', 'repo')
def test_make_rdelete_command_includes_force_twice():
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_flags').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
('repo',)
)
command = module.make_rdelete_command(
repository={'path': 'repo'},
config={},
local_borg_version='1.2.3',
rdelete_arguments=flexmock(list_archives=False, force=2),
global_arguments=flexmock(dry_run=False, log_json=False),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rdelete', '--force', '--force', 'repo')
def test_delete_repository_with_defaults_does_not_capture_output():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
command = flexmock()
flexmock(module).should_receive('make_rdelete_command').and_return(command)
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock()
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
command,
output_log_level=module.logging.ANSWER,
output_file=module.borgmatic.execute.DO_NOT_CAPTURE,
extra_environment=object,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.delete_repository(
repository={'path': 'repo'},
config={},
local_borg_version=flexmock(),
rdelete_arguments=flexmock(force=False, cache_only=False),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
)
def test_delete_repository_with_force_captures_output():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
command = flexmock()
flexmock(module).should_receive('make_rdelete_command').and_return(command)
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock()
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
command,
output_log_level=module.logging.ANSWER,
output_file=None,
extra_environment=object,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.delete_repository(
repository={'path': 'repo'},
config={},
local_borg_version=flexmock(),
rdelete_arguments=flexmock(force=True, cache_only=False),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
)
def test_delete_repository_with_cache_only_captures_output():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
command = flexmock()
flexmock(module).should_receive('make_rdelete_command').and_return(command)
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock()
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
command,
output_log_level=module.logging.ANSWER,
output_file=None,
extra_environment=object,
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.delete_repository(
repository={'path': 'repo'},
config={},
local_borg_version=flexmock(),
rdelete_arguments=flexmock(force=False, cache_only=True),
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
)
+40
View File
@@ -978,6 +978,46 @@ def test_run_actions_runs_export_key():
)
def test_run_actions_runs_delete():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.delete).should_receive('run_delete').once()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'delete': flexmock()},
config_filename=flexmock(),
config={'repositories': []},
config_paths=[],
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_rdelete():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.rdelete).should_receive('run_rdelete').once()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'rdelete': flexmock()},
config_filename=flexmock(),
config={'repositories': []},
config_paths=[],
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_borg():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
+79 -51
View File
@@ -62,7 +62,7 @@ def test_validation_error_string_contains_errors():
def test_apply_logical_validation_raises_if_unknown_repository_in_check_repositories():
flexmock(module).format_json_error = lambda error: error.message
flexmock(module).should_receive('repositories_match').and_return(False)
with pytest.raises(module.Validation_error):
module.apply_logical_validation(
@@ -75,7 +75,9 @@ def test_apply_logical_validation_raises_if_unknown_repository_in_check_reposito
)
def test_apply_logical_validation_does_not_raise_if_known_repository_path_in_check_repositories():
def test_apply_logical_validation_does_not_raise_if_known_repository_in_check_repositories():
flexmock(module).should_receive('repositories_match').and_return(True)
module.apply_logical_validation(
'config.yaml',
{
@@ -86,35 +88,6 @@ def test_apply_logical_validation_does_not_raise_if_known_repository_path_in_che
)
def test_apply_logical_validation_does_not_raise_if_known_repository_label_in_check_repositories():
module.apply_logical_validation(
'config.yaml',
{
'repositories': [
{'path': 'repo.borg', 'label': 'my_repo'},
{'path': 'other.borg', 'label': 'other_repo'},
],
'keep_secondly': 1000,
'check_repositories': ['my_repo'],
},
)
def test_apply_logical_validation_does_not_raise_if_archive_name_format_and_prefix_present():
module.apply_logical_validation(
'config.yaml',
{
'archive_name_format': '{hostname}-{now}', # noqa: FS003
'prefix': '{hostname}-', # noqa: FS003
'prefix': '{hostname}-', # noqa: FS003
},
)
def test_apply_logical_validation_does_not_raise_otherwise():
module.apply_logical_validation('config.yaml', {'keep_secondly': 1000})
def test_normalize_repository_path_passes_through_remote_repository():
repository = 'example.org:test.borg'
@@ -143,39 +116,94 @@ def test_normalize_repository_path_resolves_relative_repository():
module.normalize_repository_path(repository) == absolute
def test_repositories_match_does_not_raise():
@pytest.mark.parametrize(
'first,second,expected_result',
(
(None, None, False),
('foo', None, False),
(None, 'bar', False),
('foo', 'foo', True),
('foo', 'bar', False),
('foo*', 'foof', True),
('barf', 'bar*', True),
('foo*', 'bar*', False),
),
)
def test_glob_match_matches_globs(first, second, expected_result):
assert module.glob_match(first=first, second=second) is expected_result
def test_repositories_match_matches_on_path():
flexmock(module).should_receive('normalize_repository_path')
module.repositories_match('foo', 'bar')
def test_guard_configuration_contains_repository_does_not_raise_when_repository_in_config():
flexmock(module).should_receive('repositories_match').replace_with(
flexmock(module).should_receive('glob_match').replace_with(
lambda first, second: first == second
)
module.guard_configuration_contains_repository(
repository='repo', configurations={'config.yaml': {'repositories': ['repo']}}
module.repositories_match(
{'path': 'foo', 'label': 'my repo'}, {'path': 'foo', 'label': 'other repo'}
) is True
def test_repositories_match_matches_on_label():
flexmock(module).should_receive('normalize_repository_path')
flexmock(module).should_receive('glob_match').replace_with(
lambda first, second: first == second
)
module.repositories_match(
{'path': 'foo', 'label': 'my repo'}, {'path': 'bar', 'label': 'my repo'}
) is True
def test_repositories_match_with_different_paths_and_labels_does_not_match():
flexmock(module).should_receive('normalize_repository_path')
flexmock(module).should_receive('glob_match').replace_with(
lambda first, second: first == second
)
module.repositories_match(
{'path': 'foo', 'label': 'my repo'}, {'path': 'bar', 'label': 'other repo'}
) is False
def test_repositories_match_matches_on_string_repository():
flexmock(module).should_receive('normalize_repository_path')
flexmock(module).should_receive('glob_match').replace_with(
lambda first, second: first == second
)
module.repositories_match('foo', 'foo') is True
def test_repositories_match_with_different_string_repositories_does_not_match():
flexmock(module).should_receive('normalize_repository_path')
flexmock(module).should_receive('glob_match').replace_with(
lambda first, second: first == second
)
module.repositories_match('foo', 'bar') is False
def test_repositories_match_supports_mixed_repositories():
flexmock(module).should_receive('normalize_repository_path')
flexmock(module).should_receive('glob_match').replace_with(
lambda first, second: first == second
)
module.repositories_match({'path': 'foo', 'label': 'my foo'}, 'bar') is False
def test_guard_configuration_contains_repository_does_not_raise_when_repository_matches():
flexmock(module).should_receive('repositories_match').and_return(True)
def test_guard_configuration_contains_repository_does_not_raise_when_repository_label_in_config():
module.guard_configuration_contains_repository(
repository='repo',
configurations={'config.yaml': {'repositories': [{'path': 'foo/bar', 'label': 'repo'}]}},
)
def test_guard_configuration_contains_repository_does_not_raise_when_repository_not_given():
module.guard_configuration_contains_repository(
repository=None, configurations={'config.yaml': {'repositories': ['repo']}}
)
def test_guard_configuration_contains_repository_errors_when_repository_missing_from_config():
flexmock(module).should_receive('repositories_match').replace_with(
lambda first, second: first == second
)
def test_guard_configuration_contains_repository_errors_when_repository_does_not_match():
flexmock(module).should_receive('repositories_match').and_return(False)
with pytest.raises(ValueError):
module.guard_configuration_contains_repository(
+165
View File
@@ -0,0 +1,165 @@
from flexmock import flexmock
import borgmatic.hooks.monitor
from borgmatic.hooks import uptimekuma as module
DEFAULT_PUSH_URL = 'https://example.uptime.kuma/api/push/abcd1234'
CUSTOM_PUSH_URL = 'https://uptime.example.com/api/push/efgh5678'
def test_ping_monitor_hits_default_uptimekuma_on_fail():
hook_config = {}
flexmock(module.requests).should_receive('get').with_args(
f'{DEFAULT_PUSH_URL}?status=down&msg=fail'
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_hits_custom_uptimekuma_on_fail():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=down&msg=fail'
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_custom_uptimekuma_on_start():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=up&msg=start'
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_custom_uptimekuma_on_finish():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=up&msg=finish'
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_does_not_hit_custom_uptimekuma_on_fail_dry_run():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_does_not_hit_custom_uptimekuma_on_start_dry_run():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.START,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_does_not_hit_custom_uptimekuma_on_finish_dry_run():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_with_connection_error_logs_warning():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=down&msg=fail'
).and_raise(module.requests.exceptions.ConnectionError)
flexmock(module.logger).should_receive('warning').once()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_other_error_logs_warning():
hook_config = {'push_url': CUSTOM_PUSH_URL}
response = flexmock(ok=False)
response.should_receive('raise_for_status').and_raise(
module.requests.exceptions.RequestException
)
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=down&msg=fail'
).and_return(response)
flexmock(module.logger).should_receive('warning').once()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_invalid_run_state():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.LOG,
monitoring_log_level=1,
dry_run=True,
)
+19 -14
View File
@@ -45,23 +45,27 @@ def test_interactive_console_true_when_isatty_and_TERM_is_not_dumb(capsys):
def test_should_do_markup_respects_no_color_value():
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=True, configs={}) is False
def test_should_do_markup_respects_config_value():
assert (
module.should_do_markup(no_color=False, configs={'foo.yaml': {'output': {'color': False}}})
is False
)
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=False, configs={'foo.yaml': {'color': False}}) is False
flexmock(module).should_receive('interactive_console').and_return(True).once()
assert module.should_do_markup(no_color=False, configs={'foo.yaml': {'color': True}}) is True
def test_should_do_markup_prefers_any_false_config_value():
flexmock(module).should_receive('interactive_console').never()
assert (
module.should_do_markup(
no_color=False,
configs={
'foo.yaml': {'output': {'color': True}},
'bar.yaml': {'output': {'color': False}},
'foo.yaml': {'color': True},
'bar.yaml': {'color': False},
},
)
is False
@@ -80,25 +84,23 @@ def test_should_do_markup_respects_PY_COLORS_environment_variable():
def test_should_do_markup_prefers_no_color_value_to_config_value():
assert (
module.should_do_markup(no_color=True, configs={'foo.yaml': {'output': {'color': True}}})
is False
)
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=True, configs={'foo.yaml': {'color': True}}) is False
def test_should_do_markup_prefers_config_value_to_environment_variables():
flexmock(module.os.environ).should_receive('get').and_return('True')
flexmock(module).should_receive('to_bool').and_return(True)
flexmock(module).should_receive('interactive_console').never()
assert (
module.should_do_markup(no_color=False, configs={'foo.yaml': {'output': {'color': False}}})
is False
)
assert module.should_do_markup(no_color=False, configs={'foo.yaml': {'color': False}}) is False
def test_should_do_markup_prefers_no_color_value_to_environment_variables():
flexmock(module.os.environ).should_receive('get').and_return('True')
flexmock(module).should_receive('to_bool').and_return(True)
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=True, configs={}) is False
@@ -124,6 +126,7 @@ def test_should_do_markup_prefers_PY_COLORS_to_interactive_console_value():
def test_should_do_markup_prefers_NO_COLOR_to_interactive_console_value():
flexmock(module.os.environ).should_receive('get').with_args('PY_COLORS', None).and_return(None)
flexmock(module.os.environ).should_receive('get').with_args('NO_COLOR', None).and_return('True')
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=False, configs={}) is False
@@ -131,6 +134,7 @@ def test_should_do_markup_prefers_NO_COLOR_to_interactive_console_value():
def test_should_do_markup_respects_NO_COLOR_environment_variable():
flexmock(module.os.environ).should_receive('get').with_args('NO_COLOR', None).and_return('True')
flexmock(module.os.environ).should_receive('get').with_args('PY_COLORS', None).and_return(None)
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=False, configs={}) is False
@@ -150,6 +154,7 @@ def test_should_do_markup_prefers_NO_COLOR_to_PY_COLORS():
flexmock(module.os.environ).should_receive('get').with_args('NO_COLOR', None).and_return(
'SomeValue'
)
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=False, configs={}) is False