Compare commits

...
25 Commits
Author SHA1 Message Date
Dan Helfman fdb353d358 Bump version for release. 2023-11-19 21:14:56 -08:00
Dan Helfman 3b99f7c75a Constants support includes and command-line overrides (#745, #782) 2023-11-19 21:13:35 -08:00
Dan Helfman 8b9abc6cf8 Documentation clarifications (#791). 2023-11-15 09:05:52 -08:00
Dan Helfman da034c316a Add another mention of "skip_actions" to the docs (#701). 2023-11-08 18:22:17 -08:00
Dan Helfman 08d01d8bcd Documentation formatting. 2023-11-08 17:57:31 -08:00
Dan Helfman eef69e23ee Document the possible units of times for a configured check frequency. 2023-11-08 17:53:59 -08:00
Dan Helfman 26bb54a9dd Remove broken link in documentation (#786). 2023-11-08 10:26:09 -08:00
Dan Helfman 715e2ac127 Add test support for Python 3.12. 2023-11-07 10:17:55 -08:00
Dan Helfman f39cea4abf Remove additional Python 3.7-isms (#784). 2023-11-07 10:17:16 -08:00
Dan Helfman 22101bdd49 Drop support for Python 3.7, which has been end-of-lifed (#784). 2023-11-07 10:11:29 -08:00
Dan Helfman 13cf863d89 Fix tests (#783). 2023-11-07 10:09:31 -08:00
Dan Helfman dcf25fa041 Upgrade ruamel.yaml dependency to support version 0.18.x (#783). 2023-11-07 10:00:13 -08:00
Dan Helfman 12b75f9075 Update documentation about logging changes from version 1.8.3 (#665). 2023-11-06 21:13:46 -08:00
Dan HelfmanandGitHub 9baf06a2f7 Fix typo.
Typo
2023-11-04 08:56:39 -07:00
tdltdc 56302e22cd Typo 2023-11-04 15:05:19 +01:00
Dan Helfman 6cc93c4eb9 Fix environment variable interpolation within configured repository paths (#782). 2023-11-03 21:16:04 -07:00
Dan Helfman 2da43239f6 Fix docs: minor typos (#781).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/781
2023-11-03 00:59:29 +00:00
debuglevel 4beef36d3c Update docs/how-to/inspect-your-backups.md 2023-11-02 23:14:31 +00:00
debuglevel eacfbd742b Typo 2023-11-02 23:13:45 +00:00
debuglevel 82a85986b6 Typo 2023-11-02 22:57:46 +00:00
Dan Helfman ef448e2dd1 Add a "skip_actions" option to skip running particular actions (#701). 2023-10-31 21:54:41 -07:00
Dan Helfman c3efe1b90e Only parse "--override" values as complex data types when they're for options of those types (#779). 2023-10-29 19:02:28 -07:00
Dan Helfman d85c1ee216 Correct changelog addition (#779). 2023-10-29 16:25:40 -07:00
Dan Helfman b47088067c Add a "--match-archives" flag to the "check" action (#779). 2023-10-29 16:22:39 -07:00
Dan Helfman c5732aa4fc Fix home page CSS layout to prevent overflow at certain window widths (#777). 2023-10-27 14:12:35 -07:00
37 changed files with 730 additions and 271 deletions
+18
View File
@@ -1,3 +1,21 @@
1.8.5
* #701: Add a "skip_actions" option to skip running particular actions, handy for append-only or
checkless configurations. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#skipping-actions
* #701: Deprecate the "disabled" value for the "checks" option in favor of the new "skip_actions"
option.
* #745: Constants now apply to included configuration, not just the file doing the includes. As a
side effect of this change, constants no longer apply to option names and only substitute into
configuration values.
* #779: Add a "--match-archives" flag to the "check" action for selecting the archives to check,
overriding the existing "archive_name_format" and "match_archives" options in configuration.
* #779: Only parse "--override" values as complex data types when they're for options of those
types.
* #782: Fix environment variable interpolation within configured repository paths.
* #782: Add configuration constant overriding via the existing "--override" flag.
* #783: Upgrade ruamel.yaml dependency to support version 0.18.x.
* #784: Drop support for Python 3.7, which has been end-of-lifed.
1.8.4
* #715: Add a monitoring hook for sending backup status to a variety of monitoring services via the
Apprise library. See the documentation for more information:
+1 -4
View File
@@ -39,13 +39,10 @@ def run_check(
repository['path'],
config,
local_borg_version,
check_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
progress=check_arguments.progress,
repair=check_arguments.repair,
only_checks=check_arguments.only,
force=check_arguments.force,
)
borgmatic.hooks.command.execute_hook(
config.get('after_check'),
+2 -6
View File
@@ -1,12 +1,8 @@
import importlib.metadata
import json
import logging
import os
try:
import importlib_metadata
except ModuleNotFoundError: # pragma: nocover
import importlib.metadata as importlib_metadata
import borgmatic.borg.create
import borgmatic.borg.state
import borgmatic.config.validate
@@ -39,7 +35,7 @@ def create_borgmatic_manifest(config, config_paths, dry_run):
with open(borgmatic_manifest_path, 'w') as config_list_file:
json.dump(
{
'borgmatic_version': importlib_metadata.version('borgmatic'),
'borgmatic_version': importlib.metadata.version('borgmatic'),
'config_paths': config_paths,
},
config_list_file,
+24 -18
View File
@@ -39,7 +39,11 @@ def parse_checks(config, only_checks=None):
check_config['name'] for check_config in (config.get('checks', None) or DEFAULT_CHECKS)
)
checks = tuple(check.lower() for check in checks)
if 'disabled' in checks:
logger.warning(
'The "disabled" value for the "checks" option is deprecated and will be removed from a future release; use "skip_actions" instead'
)
if len(checks) > 1:
logger.warning(
'Multiple checks are configured, but one of them is "disabled"; not running any checks'
@@ -119,6 +123,9 @@ def filter_checks_on_frequency(
Raise ValueError if a frequency cannot be parsed.
'''
if not checks:
return checks
filtered_checks = list(checks)
if force:
@@ -149,11 +156,13 @@ def filter_checks_on_frequency(
return tuple(filtered_checks)
def make_archive_filter_flags(local_borg_version, config, checks, check_last=None, prefix=None):
def make_archive_filter_flags(
local_borg_version, config, checks, check_arguments, check_last=None, prefix=None
):
'''
Given the local Borg version, a configuration dict, a parsed sequence of checks, the check last
value, and a consistency check prefix, transform the checks into tuple of command-line flags for
filtering archives in a check command.
Given the local Borg version, a configuration dict, a parsed sequence of checks, check arguments
as an argparse.Namespace instance, the check last value, and a consistency check prefix,
transform the checks into tuple of command-line flags for filtering archives in a check command.
If a check_last value is given and "archives" is in checks, then include a "--last" flag. And if
a prefix value is given and "archives" is in checks, then include a "--match-archives" flag.
@@ -168,7 +177,7 @@ def make_archive_filter_flags(local_borg_version, config, checks, check_last=Non
if prefix
else (
flags.make_match_archives_flags(
config.get('match_archives'),
check_arguments.match_archives or config.get('match_archives'),
config.get('archive_name_format'),
local_borg_version,
)
@@ -353,18 +362,15 @@ def check_archives(
repository_path,
config,
local_borg_version,
check_arguments,
global_arguments,
local_path='borg',
remote_path=None,
progress=None,
repair=None,
only_checks=None,
force=None,
):
'''
Given a local or remote repository path, a configuration dict, local/remote commands to run,
whether to include progress information, whether to attempt a repair, and an optional list of
checks to use instead of configured checks, check the contained Borg archives for consistency.
Given a local or remote repository path, a configuration dict, the local Borg version, check
arguments as an argparse.Namespace instance, global arguments, and local/remote commands to run,
check the contained Borg archives for consistency.
If there are no consistency checks to run, skip running them.
@@ -389,11 +395,11 @@ def check_archives(
check_last = config.get('check_last', None)
prefix = config.get('prefix')
configured_checks = parse_checks(config, only_checks)
configured_checks = parse_checks(config, check_arguments.only_checks)
lock_wait = None
extra_borg_options = config.get('extra_borg_options', {}).get('check', '')
archive_filter_flags = make_archive_filter_flags(
local_borg_version, config, configured_checks, check_last, prefix
local_borg_version, config, configured_checks, check_arguments, check_last, prefix
)
archives_check_id = make_archives_check_id(archive_filter_flags)
@@ -401,7 +407,7 @@ def check_archives(
config,
borg_repository_id,
configured_checks,
force,
check_arguments.force,
archives_check_id,
)
@@ -416,13 +422,13 @@ def check_archives(
full_command = (
(local_path, 'check')
+ (('--repair',) if repair else ())
+ (('--repair',) if check_arguments.repair else ())
+ make_check_flags(checks, archive_filter_flags)
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags
+ (('--progress',) if progress else ())
+ (('--progress',) if check_arguments.progress else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
@@ -431,7 +437,7 @@ def check_archives(
# The Borg repair option triggers an interactive prompt, which won't work when output is
# captured. And progress messes with the terminal directly.
if repair or progress:
if check_arguments.repair or check_arguments.progress:
execute_command(
full_command, output_file=DO_NOT_CAPTURE, extra_environment=borg_environment
)
+8 -1
View File
@@ -604,11 +604,18 @@ def make_parsers():
action='store_true',
help='Attempt to repair any inconsistencies found (for interactive use)',
)
check_group.add_argument(
'-a',
'--match-archives',
'--glob-archives',
metavar='PATTERN',
help='Only check archives with names matching this pattern',
)
check_group.add_argument(
'--only',
metavar='CHECK',
choices=('repository', 'archives', 'data', 'extract'),
dest='only',
dest='only_checks',
action='append',
help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks (subject to configured frequency, can specify flag multiple times)',
)
+26 -23
View File
@@ -1,4 +1,5 @@
import collections
import importlib.metadata
import json
import logging
import os
@@ -9,11 +10,6 @@ from subprocess import CalledProcessError
import colorama
try:
import importlib_metadata
except ModuleNotFoundError: # pragma: nocover
import importlib.metadata as importlib_metadata
import borgmatic.actions.borg
import borgmatic.actions.break_lock
import borgmatic.actions.check
@@ -70,6 +66,12 @@ def run_configuration(config_filename, config, arguments):
using_primary_action = {'create', 'prune', 'compact', 'check'}.intersection(arguments)
monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
monitoring_hooks_are_activated = using_primary_action and monitoring_log_level != DISABLED
skip_actions = config.get('skip_actions')
if skip_actions:
logger.debug(
f"{config_filename}: Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
)
try:
local_borg_version = borg_version.local_borg_version(config, local_path)
@@ -274,6 +276,7 @@ def run_actions(
'repositories': ','.join([repo['path'] for repo in config['repositories']]),
'log_file': global_arguments.log_file if global_arguments.log_file else '',
}
skip_actions = set(config.get('skip_actions', {}))
command.execute_hook(
config.get('before_actions'),
@@ -285,7 +288,7 @@ def run_actions(
)
for action_name, action_arguments in arguments.items():
if action_name == 'rcreate':
if action_name == 'rcreate' and action_name not in skip_actions:
borgmatic.actions.rcreate.run_rcreate(
repository,
config,
@@ -295,7 +298,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'transfer':
elif action_name == 'transfer' and action_name not in skip_actions:
borgmatic.actions.transfer.run_transfer(
repository,
config,
@@ -305,7 +308,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'create':
elif action_name == 'create' and action_name not in skip_actions:
yield from borgmatic.actions.create.run_create(
config_filename,
repository,
@@ -318,7 +321,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'prune':
elif action_name == 'prune' and action_name not in skip_actions:
borgmatic.actions.prune.run_prune(
config_filename,
repository,
@@ -331,7 +334,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'compact':
elif action_name == 'compact' and action_name not in skip_actions:
borgmatic.actions.compact.run_compact(
config_filename,
repository,
@@ -344,7 +347,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'check':
elif action_name == 'check' and action_name not in skip_actions:
if checks.repository_enabled_for_checks(repository, config):
borgmatic.actions.check.run_check(
config_filename,
@@ -357,7 +360,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'extract':
elif action_name == 'extract' and action_name not in skip_actions:
borgmatic.actions.extract.run_extract(
config_filename,
repository,
@@ -369,7 +372,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'export-tar':
elif action_name == 'export-tar' and action_name not in skip_actions:
borgmatic.actions.export_tar.run_export_tar(
repository,
config,
@@ -379,7 +382,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'mount':
elif action_name == 'mount' and action_name not in skip_actions:
borgmatic.actions.mount.run_mount(
repository,
config,
@@ -389,7 +392,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'restore':
elif action_name == 'restore' and action_name not in skip_actions:
borgmatic.actions.restore.run_restore(
repository,
config,
@@ -399,7 +402,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'rlist':
elif action_name == 'rlist' and action_name not in skip_actions:
yield from borgmatic.actions.rlist.run_rlist(
repository,
config,
@@ -409,7 +412,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'list':
elif action_name == 'list' and action_name not in skip_actions:
yield from borgmatic.actions.list.run_list(
repository,
config,
@@ -419,7 +422,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'rinfo':
elif action_name == 'rinfo' and action_name not in skip_actions:
yield from borgmatic.actions.rinfo.run_rinfo(
repository,
config,
@@ -429,7 +432,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'info':
elif action_name == 'info' and action_name not in skip_actions:
yield from borgmatic.actions.info.run_info(
repository,
config,
@@ -439,7 +442,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'break-lock':
elif action_name == 'break-lock' and action_name not in skip_actions:
borgmatic.actions.break_lock.run_break_lock(
repository,
config,
@@ -449,7 +452,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'export':
elif action_name == 'export' and action_name not in skip_actions:
borgmatic.actions.export_key.run_export_key(
repository,
config,
@@ -459,7 +462,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'borg':
elif action_name == 'borg' and action_name not in skip_actions:
borgmatic.actions.borg.run_borg(
repository,
config,
@@ -827,7 +830,7 @@ def main(extra_summary_logs=[]): # pragma: no cover
global_arguments = arguments['global']
if global_arguments.version:
print(importlib_metadata.version('borgmatic'))
print(importlib.metadata.version('borgmatic'))
sys.exit(0)
if global_arguments.bash_completion:
print(borgmatic.commands.completion.bash.bash_completion())
+5 -5
View File
@@ -1,9 +1,9 @@
def repository_enabled_for_checks(repository, consistency):
def repository_enabled_for_checks(repository, config):
'''
Given a repository name and a consistency configuration dict, return whether the repository
is enabled to have consistency checks run.
Given a repository name and a configuration dict, return whether the
repository is enabled to have consistency checks run.
'''
if not consistency.get('check_repositories'):
if not config.get('check_repositories'):
return True
return repository in consistency['check_repositories']
return repository in config['check_repositories']
+47
View File
@@ -0,0 +1,47 @@
def coerce_scalar(value):
'''
Given a configuration value, coerce it to an integer or a boolean as appropriate and return the
result.
'''
try:
return int(value)
except (TypeError, ValueError):
pass
if value == 'true' or value == 'True':
return True
if value == 'false' or value == 'False':
return False
return value
def apply_constants(value, constants):
'''
Given a configuration value (bool, dict, int, list, or string) and a dict of named constants,
replace any configuration string values of the form "{constant}" (or containing it) with the
value of the correspondingly named key from the constants. Recurse as necessary into nested
configuration to find values to replace.
For instance, if a configuration value contains "{foo}", replace it with the value of the "foo"
key found within the configuration's "constants".
Return the configuration value and modify the original.
'''
if not value or not constants:
return value
if isinstance(value, str):
for constant_name, constant_value in constants.items():
value = value.replace('{' + constant_name + '}', str(constant_value))
# Support constants within non-string scalars by coercing the value to its appropriate type.
value = coerce_scalar(value)
elif isinstance(value, list):
for index, list_value in enumerate(value):
value[index] = apply_constants(list_value, constants)
elif isinstance(value, dict):
for option_name, option_value in value.items():
value[option_name] = apply_constants(option_value, constants)
return value
+20 -14
View File
@@ -1,21 +1,22 @@
import os
import re
_VARIABLE_PATTERN = re.compile(
VARIABLE_PATTERN = re.compile(
r'(?P<escape>\\)?(?P<variable>\$\{(?P<name>[A-Za-z0-9_]+)((:?-)(?P<default>[^}]+))?\})'
)
def _resolve_string(matcher):
def resolve_string(matcher):
'''
Get the value from environment given a matcher containing a name and an optional default value.
If the variable is not defined in environment and no default value is provided, an Error is raised.
Given a matcher containing a name and an optional default value, get the value from environment.
Raise ValueError if the variable is not defined in environment and no default value is provided.
'''
if matcher.group('escape') is not None:
# in case of escaped envvar, unescape it
# In the case of an escaped environment variable, unescape it.
return matcher.group('variable')
# resolve the env var
# Resolve the environment variable.
name, default = matcher.group('name'), matcher.group('default')
out = os.getenv(name, default=default)
@@ -27,19 +28,24 @@ def _resolve_string(matcher):
def resolve_env_variables(item):
'''
Resolves variables like or ${FOO} from given configuration with values from process environment
Supported formats:
- ${FOO} will return FOO env variable
- ${FOO-bar} or ${FOO:-bar} will return FOO env variable if it exists, else "bar"
Resolves variables like or ${FOO} from given configuration with values from process environment.
If any variable is missing in environment and no default value is provided, an Error is raised.
Supported formats:
* ${FOO} will return FOO env variable
* ${FOO-bar} or ${FOO:-bar} will return FOO env variable if it exists, else "bar"
Raise if any variable is missing in environment and no default value is provided.
'''
if isinstance(item, str):
return _VARIABLE_PATTERN.sub(_resolve_string, item)
return VARIABLE_PATTERN.sub(resolve_string, item)
if isinstance(item, list):
for i, subitem in enumerate(item):
item[i] = resolve_env_variables(subitem)
for index, subitem in enumerate(item):
item[index] = resolve_env_variables(subitem)
if isinstance(item, dict):
for key, value in item.items():
item[key] = resolve_env_variables(value)
return item
+10 -8
View File
@@ -3,7 +3,7 @@ import io
import os
import re
from ruamel import yaml
import ruamel.yaml
from borgmatic.config import load, normalize
@@ -17,7 +17,7 @@ def insert_newline_before_comment(config, field_name):
field and its comments.
'''
config.ca.items[field_name][1].insert(
0, yaml.tokens.CommentToken('\n', yaml.error.CommentMark(0), None)
0, ruamel.yaml.tokens.CommentToken('\n', ruamel.yaml.error.CommentMark(0), None)
)
@@ -32,12 +32,12 @@ def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
return example
if schema_type == 'array':
config = yaml.comments.CommentedSeq(
config = ruamel.yaml.comments.CommentedSeq(
[schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
)
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
elif schema_type == 'object':
config = yaml.comments.CommentedMap(
config = ruamel.yaml.comments.CommentedMap(
[
(field_name, schema_to_sample_configuration(sub_schema, level + 1))
for field_name, sub_schema in schema['properties'].items()
@@ -101,7 +101,7 @@ def render_configuration(config):
'''
Given a config data structure of nested OrderedDicts, render the config as YAML and return it.
'''
dumper = yaml.YAML()
dumper = ruamel.yaml.YAML(typ='rt')
dumper.indent(mapping=INDENT, sequence=INDENT + SEQUENCE_INDENT, offset=INDENT)
rendered = io.StringIO()
dumper.dump(config, rendered)
@@ -236,7 +236,9 @@ def merge_source_configuration_into_destination(destination_config, source_confi
for field_name, source_value in source_config.items():
# Since this key/value is from the source configuration, leave it uncommented and remove any
# sentinel that would cause it to get commented out.
remove_commented_out_sentinel(destination_config, field_name)
remove_commented_out_sentinel(
ruamel.yaml.comments.CommentedMap(destination_config), field_name
)
# This is a mapping. Recurse for this key/value.
if isinstance(source_value, collections.abc.Mapping):
@@ -248,7 +250,7 @@ def merge_source_configuration_into_destination(destination_config, source_confi
# This is a sequence. Recurse for each item in it.
if isinstance(source_value, collections.abc.Sequence) and not isinstance(source_value, str):
destination_value = destination_config[field_name]
destination_config[field_name] = yaml.comments.CommentedSeq(
destination_config[field_name] = ruamel.yaml.comments.CommentedSeq(
[
merge_source_configuration_into_destination(
destination_value[index] if index < len(destination_value) else None,
@@ -275,7 +277,7 @@ def generate_sample_configuration(
schema. If a source filename is provided, merge the parsed contents of that configuration into
the generated configuration.
'''
schema = yaml.round_trip_load(open(schema_filename))
schema = ruamel.yaml.YAML(typ='safe').load(open(schema_filename))
source_config = None
if source_filename:
+2 -20
View File
@@ -1,6 +1,5 @@
import functools
import itertools
import json
import logging
import operator
import os
@@ -159,8 +158,7 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
def load_configuration(filename):
'''
Load the given configuration file and return its contents as a data structure of nested dicts
and lists. Also, replace any "{constant}" strings with the value of the "constant" key in the
"constants" option of the configuration file.
and lists.
Raise ruamel.yaml.error.YAMLError if something goes wrong parsing the YAML, or RecursionError
if there are too many recursive includes.
@@ -179,23 +177,7 @@ def load_configuration(filename):
yaml.Constructor = Include_constructor_with_include_directory
with open(filename) as file:
file_contents = file.read()
config = yaml.load(file_contents)
try:
has_constants = bool(config and 'constants' in config)
except TypeError:
has_constants = False
if has_constants:
for key, value in config['constants'].items():
value = json.dumps(value)
file_contents = file_contents.replace(f'{{{key}}}', value.strip('"'))
config = yaml.load(file_contents)
del config['constants']
return config
return yaml.load(file.read())
def filter_omitted_nodes(nodes, values):
+44 -13
View File
@@ -22,13 +22,19 @@ def set_values(config, keys, value):
set_values(config[first_key], keys[1:], value)
def convert_value_type(value):
def convert_value_type(value, option_type):
'''
Given a string value, determine its logical type (string, boolean, integer, etc.), and return it
converted to that type.
Given a string value and its schema type as a string, determine its logical type (string,
boolean, integer, etc.), and return it converted to that type.
If the option type is a string, leave the value as a string so that special characters in it
don't get interpreted as YAML during conversion.
Raise ruamel.yaml.error.YAMLError if there's a parse issue with the YAML.
'''
if option_type == 'string':
return value
return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value))
@@ -46,11 +52,32 @@ def strip_section_names(parsed_override_key):
return parsed_override_key
def parse_overrides(raw_overrides):
def type_for_option(schema, option_keys):
'''
Given a sequence of configuration file override strings in the form of "option.suboption=value",
parse and return a sequence of tuples (keys, values), where keys is a sequence of strings. For
instance, given the following raw overrides:
Given a configuration schema and a sequence of keys identifying an option, e.g.
('extra_borg_options', 'init'), return the schema type of that option as a string.
Return None if the option or its type cannot be found in the schema.
'''
option_schema = schema
for key in option_keys:
try:
option_schema = option_schema['properties'][key]
except KeyError:
return None
try:
return option_schema['type']
except KeyError:
return None
def parse_overrides(raw_overrides, schema):
'''
Given a sequence of configuration file override strings in the form of "option.suboption=value"
and a configuration schema dict, parse and return a sequence of tuples (keys, values), where
keys is a sequence of strings. For instance, given the following raw overrides:
['my_option.suboption=value1', 'other_option=value2']
@@ -71,10 +98,13 @@ def parse_overrides(raw_overrides):
for raw_override in raw_overrides:
try:
raw_keys, value = raw_override.split('=', 1)
keys = strip_section_names(tuple(raw_keys.split('.')))
option_type = type_for_option(schema, keys)
parsed_overrides.append(
(
strip_section_names(tuple(raw_keys.split('.'))),
convert_value_type(value),
keys,
convert_value_type(value, option_type),
)
)
except ValueError:
@@ -87,12 +117,13 @@ def parse_overrides(raw_overrides):
return tuple(parsed_overrides)
def apply_overrides(config, raw_overrides):
def apply_overrides(config, schema, raw_overrides):
'''
Given a configuration dict and a sequence of configuration file override strings in the form of
"option.suboption=value", parse each override and set it the configuration dict.
Given a configuration dict, a corresponding configuration schema dict, and a sequence of
configuration file override strings in the form of "option.suboption=value", parse each override
and set it into the configuration dict.
'''
overrides = parse_overrides(raw_overrides)
overrides = parse_overrides(raw_overrides, schema)
for keys, value in overrides:
set_values(config, keys, value)
+31 -14
View File
@@ -216,7 +216,7 @@ properties:
Store configuration files used to create a backup in the backup
itself. Defaults to true. Changing this to false prevents "borgmatic
bootstrap" from extracting configuration files from the backup.
example: true
example: false
source_directories_must_exist:
type: boolean
description: |
@@ -287,14 +287,17 @@ properties:
retry_wait:
type: integer
description: |
Wait time between retries (in seconds) to allow transient issues to
pass. Increases after each retry as a form of backoff. Defaults to 0
(no wait).
Wait time between retries (in seconds) to allow transient issues
to pass. Increases after each retry by that same wait time as a
form of backoff. Defaults to 0 (no wait).
example: 10
temporary_directory:
type: string
description: |
Directory where temporary files are stored. Defaults to $TMPDIR.
Directory where temporary Borg files are stored. Defaults to
$TMPDIR. See "Resource Usage" at
https://borgbackup.readthedocs.io/en/stable/usage/general.html for
details.
example: /path/to/tmpdir
ssh_command:
type: string
@@ -423,7 +426,9 @@ properties:
command-line invocation.
keep_within:
type: string
description: Keep all archives within this time interval.
description: |
Keep all archives within this time interval. See "skip_actions" for
disabling pruning altogether.
example: 3H
keep_secondly:
type: integer
@@ -479,13 +484,13 @@ properties:
- disabled
description: |
Name of consistency check to run: "repository",
"archives", "data", and/or "extract". Set to "disabled"
to disable all consistency checks. "repository" checks
the consistency of the repository, "archives" checks all
of the archives, "data" verifies the integrity of the
data within the archives, and "extract" does an
extraction dry-run of the most recent archive. Note that
"data" implies "archives".
"archives", "data", 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, and "extract"
does an extraction dry-run of the most recent archive.
Note that "data" implies "archives". See "skip_actions"
for disabling checks altogether.
example: repository
frequency:
type: string
@@ -525,6 +530,18 @@ properties:
Apply color to console output. Can be overridden with --no-color
command-line flag. Defaults to true.
example: false
skip_actions:
type: array
items:
type: string
description: |
List of one or more actions to skip running for this configuration
file, even if specified on the command-line (explicitly or
implicitly). This is handy for append-only configurations where you
never want to run "compact" or checkless configuration where you
want to skip "check". Defaults to not skipping any actions.
example:
- compact
before_actions:
type: array
items:
@@ -1493,7 +1510,7 @@ properties:
ends, or errors.
example: https://cronhub.io/ping/1f5e3410-254c-5587
description: |
Configuration for a monitoring integration with Crunhub. Create an
Configuration for a monitoring integration with Cronhub. Create an
account at https://cronhub.io if you'd like to use this service. See
borgmatic monitoring documentation for details.
loki:
+6 -3
View File
@@ -4,7 +4,7 @@ import jsonschema
import ruamel.yaml
import borgmatic.config
from borgmatic.config import environment, load, normalize, override
from borgmatic.config import constants, environment, load, normalize, override
def schema_filename():
@@ -109,11 +109,14 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
except (ruamel.yaml.error.YAMLError, RecursionError) as error:
raise Validation_error(config_filename, (str(error),))
override.apply_overrides(config, overrides)
logs = normalize.normalize(config_filename, config)
override.apply_overrides(config, schema, overrides)
constants.apply_constants(config, config.get('constants') if config else {})
if resolve_env:
environment.resolve_env_variables(config)
logs = normalize.normalize(config_filename, config)
try:
validator = jsonschema.Draft7Validator(schema)
except AttributeError: # pragma: no cover
+2 -2
View File
@@ -2,13 +2,13 @@
font-size: 1rem; /* Reset */
}
.elv-toc details {
--details-force-closed: (max-width: 63.9375em); /* 1023px */
--details-force-closed: (max-width: 79.9375em); /* 1023px */
}
.elv-toc details > summary {
font-size: 1.375rem; /* 22px /16 */
margin-bottom: .5em;
}
@media (min-width: 64em) { /* 1024px */
@media (min-width: 80em) {
.elv-toc {
position: absolute;
left: 3rem;
+3 -3
View File
@@ -121,7 +121,7 @@ main h1:first-child,
main .elv-toc + h1 {
border-bottom: 2px dotted #666;
}
@media (min-width: 64em) { /* 1024px */
@media (min-width: 80em) {
main .elv-toc + h1,
main .elv-toc + h2 {
margin-top: 0;
@@ -243,10 +243,10 @@ footer.elv-layout {
.elv-layout-full {
max-width: none;
}
@media (min-width: 64em) { /* 1024px */
@media (min-width: 80em) {
.elv-layout-toc {
padding-left: 15rem;
max-width: 60rem;
max-width: 76rem;
margin-right: 1rem;
position: relative;
}
@@ -126,7 +126,7 @@ for more information.
## Hook output
Any output produced by your hooks shows up both at the console and in syslog
(when run in a non-interactive console). For more information, read about <a
(when enabled). For more information, read about <a
href="https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/">inspecting
your backups</a>.
+29 -6
View File
@@ -51,6 +51,11 @@ cron job), while only running expensive consistency checks with `check` on a
much less frequent basis (e.g. with `borgmatic check` called from a separate
cron job).
<span class="minilink minilink-addedin">New in version 1.8.5</span> Instead of
(or in addition to) specifying actions on the command-line, you can configure
borgmatic to [skip particular
actions](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#skipping-actions).
### Consistency check configuration
@@ -116,8 +121,17 @@ this option in the `consistency:` section of your configuration.
This tells borgmatic to run the `repository` consistency check at most once
every two weeks for a given repository and the `archives` check at most once a
month. The `frequency` value is a number followed by a unit of time, e.g. "3
days", "1 week", "2 months", etc.
month. The `frequency` value is a number followed by a unit of time, e.g. `3
days`, `1 week`, `2 months`, etc. The set of possible time units is as
follows (singular or plural):
* `second`
* `minute`
* `hour`
* `day`
* `week` (7 days)
* `month` (30 days)
* `year` (365 days)
The `frequency` defaults to `always` for a check configured without a
`frequency`, which means run this check every time checks run. But if you omit
@@ -162,7 +176,16 @@ location:
If that's still too slow, you can disable consistency checks entirely,
either for a single repository or for all repositories.
Disabling all consistency checks looks like this:
<span class="minilink minilink-addedin">New in version 1.8.5</span> Disabling
all consistency checks looks like this:
```yaml
skip_actions:
- check
```
<span class="minilink minilink-addedin">Prior to version 1.8.5</span> Use this
configuration instead:
```yaml
checks:
@@ -170,10 +193,10 @@ checks:
```
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `consistency:` section of your configuration.
`checks:` in the `consistency:` section of your configuration.
<span class="minilink minilink-addedin">Prior to version 1.6.2</span> `checks`
was a plain list of strings without the `name:` part. For instance:
<span class="minilink minilink-addedin">Prior to version 1.6.2</span>
`checks:` was a plain list of strings without the `name:` part. For instance:
```yaml
checks:
+4 -3
View File
@@ -149,9 +149,10 @@ borgmatic umount --mount-point /mnt
<span class="minilink minilink-addedin">New in version 1.7.15</span> borgmatic
automatically stores all the configuration files used to create an archive
inside the archive itself. This is useful in cases where you've lost a
configuration file or you want to see what configurations were used to create a
particular archive.
inside the archive itself. They are stored in the archive using their full
paths from the machine being backed up. This is useful in cases where you've
lost a configuration file or you want to see what configurations were used to
create a particular archive.
To extract the configuration files from an archive, use the `config bootstrap`
action. For example:
+16 -13
View File
@@ -116,27 +116,30 @@ archive, complete with file sizes.
## Logging
By default, borgmatic logs to a local syslog-compatible daemon if one is
present and borgmatic is running in a non-interactive console. Where those
logs show up depends on your particular system. If you're using systemd, try
running `journalctl -xe`. Otherwise, try viewing `/var/log/syslog` or
similar.
You can customize the log level used for syslog logging with the
`--syslog-verbosity` flag, and this is independent from the console logging
`--verbosity` flag described above. For instance, to get additional
information about the progress of the backup as it proceeds:
By default, borgmatic logs to the console. You can enable simultaneous syslog
logging and customize its log level with the `--syslog-verbosity` flag, which
is independent from the console logging `--verbosity` flag described above.
For instance, to enable syslog logging, run:
```bash
borgmatic --syslog-verbosity 1
```
Or to increase syslog logging to include debug spew:
To increase syslog logging further to include debugging information, run:
```bash
borgmatic --syslog-verbosity 2
```
See above for further details about the verbosity levels.
Where these logs show up depends on your particular system. If you're using
systemd, try running `journalctl -xe`. Otherwise, try viewing
`/var/log/syslog` or similar.
<span class="minilink minilink-addedin">Prior to version 1.8.3</span>borgmatic
logged to syslog by default whenever run at a non-interactive console.
### Rate limiting
If you are using rsyslog or systemd's journal, be aware that by default they
@@ -165,7 +168,7 @@ Note that if you use the `--log-file` flag, you are responsible for rotating
the log file so it doesn't grow too large, for example with
[logrotate](https://wiki.archlinux.org/index.php/Logrotate).
You can the `--log-file-verbosity` flag to customize the log file's log level:
You can use the `--log-file-verbosity` flag to customize the log file's log level:
```bash
borgmatic --log-file /path/to/file.log --log-file-verbosity 2
@@ -197,5 +200,5 @@ See the [Python logging
documentation](https://docs.python.org/3/library/logging.html#logrecord-attributes)
for additional placeholders.
Note that this `--log-file-format` flg only applies to the specified
Note that this `--log-file-format` flag only applies to the specified
`--log-file` and not to syslog or other logging.
+1 -1
View File
@@ -151,7 +151,7 @@ in newer versions of borgmatic.
## Configuration includes
Once you have multiple different configuration files, you might want to share
common configuration options across these files with having to copy and paste
common configuration options across these files without having to copy and paste
them. To achieve this, you can put fragments of common configuration options
into a file and then include or inline that file into one or more borgmatic
configuration files.
+15
View File
@@ -282,6 +282,21 @@ due to things like file damage. For instance:
sudo borgmatic --verbosity 1 --list --stats
```
### Skipping actions
<span class="minilink minilink-addedin">New in version 1.8.5</span> You can
configure borgmatic to skip running certain actions (default or otherwise).
For instance, to always skip the `compact` action when using [Borg's
append-only
mode](https://borgbackup.readthedocs.io/en/stable/usage/notes.html#append-only-mode-forbid-compaction),
set the `skip_actions` option:
```
skip_actions:
- compact
```
## Autopilot
Running backups manually is good for validating your configuration, but I'm
-2
View File
@@ -21,5 +21,3 @@ version](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#configuration
```yaml
{% include borgmatic/config.yaml %}
```
Note that you can also [download this configuration
+3 -3
View File
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.8.4'
VERSION = '1.8.5'
setup(
@@ -33,10 +33,10 @@ setup(
'jsonschema',
'packaging',
'requests',
'ruamel.yaml>0.15.0,<0.18.0',
'ruamel.yaml>0.15.0',
'setuptools',
),
extras_require={"Apprise": ["apprise"]},
include_package_data=True,
python_requires='>=3.7',
python_requires='>=3.8',
)
+5 -8
View File
@@ -14,13 +14,12 @@ flake8-use-fstring==1.4
flake8-variables-names==0.0.5
flexmock==0.11.3
idna==3.4
importlib_metadata==6.3.0; python_version < '3.8'
isort==5.12.0
jsonschema==4.17.3
Markdown==3.4.1
mccabe==0.7.0
packaging==23.1
pathspec==0.11.1; python_version >= '3.8'
pathspec==0.11.1
pluggy==1.0.0
py==1.11.0
pycodestyle==2.10.0
@@ -28,10 +27,8 @@ pyflakes==3.0.1
pytest==7.3.0
pytest-cov==4.0.0
PyYAML>5.0.0
regex; python_version >= '3.8'
regex
requests==2.31.0
ruamel.yaml>0.15.0,<0.18.0
toml==0.10.2; python_version >= '3.8'
typed-ast; python_version >= '3.8'
typing-extensions==4.5.0; python_version < '3.8'
zipp==3.15.0; python_version < '3.8'
ruamel.yaml>0.15.0
toml==0.10.2
typed-ast
+22 -12
View File
@@ -10,7 +10,7 @@ from borgmatic.config import generate as module
def test_insert_newline_before_comment_does_not_raise():
field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)])
config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Comment')
module.insert_newline_before_comment(config, field_name)
@@ -125,14 +125,16 @@ def test_write_configuration_with_already_existing_directory_does_not_raise():
def test_add_comments_to_configuration_sequence_of_strings_does_not_raise():
config = module.yaml.comments.CommentedSeq(['foo', 'bar'])
config = module.ruamel.yaml.comments.CommentedSeq(['foo', 'bar'])
schema = {'type': 'array', 'items': {'type': 'string'}}
module.add_comments_to_configuration_sequence(config, schema)
def test_add_comments_to_configuration_sequence_of_maps_does_not_raise():
config = module.yaml.comments.CommentedSeq([module.yaml.comments.CommentedMap([('foo', 'yo')])])
config = module.ruamel.yaml.comments.CommentedSeq(
[module.ruamel.yaml.comments.CommentedMap([('foo', 'yo')])]
)
schema = {
'type': 'array',
'items': {'type': 'object', 'properties': {'foo': {'description': 'yo'}}},
@@ -142,7 +144,9 @@ def test_add_comments_to_configuration_sequence_of_maps_does_not_raise():
def test_add_comments_to_configuration_sequence_of_maps_without_description_does_not_raise():
config = module.yaml.comments.CommentedSeq([module.yaml.comments.CommentedMap([('foo', 'yo')])])
config = module.ruamel.yaml.comments.CommentedSeq(
[module.ruamel.yaml.comments.CommentedMap([('foo', 'yo')])]
)
schema = {'type': 'array', 'items': {'type': 'object', 'properties': {'foo': {}}}}
module.add_comments_to_configuration_sequence(config, schema)
@@ -150,7 +154,7 @@ def test_add_comments_to_configuration_sequence_of_maps_without_description_does
def test_add_comments_to_configuration_object_does_not_raise():
# Ensure that it can deal with fields both in the schema and missing from the schema.
config = module.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)])
config = module.ruamel.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)])
schema = {
'type': 'object',
'properties': {'foo': {'description': 'Foo'}, 'bar': {'description': 'Bar'}},
@@ -160,7 +164,7 @@ def test_add_comments_to_configuration_object_does_not_raise():
def test_add_comments_to_configuration_object_with_skip_first_does_not_raise():
config = module.yaml.comments.CommentedMap([('foo', 33)])
config = module.ruamel.yaml.comments.CommentedMap([('foo', 33)])
schema = {'type': 'object', 'properties': {'foo': {'description': 'Foo'}}}
module.add_comments_to_configuration_object(config, schema, skip_first=True)
@@ -168,7 +172,7 @@ def test_add_comments_to_configuration_object_with_skip_first_does_not_raise():
def test_remove_commented_out_sentinel_keeps_other_comments():
field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)])
config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.\nCOMMENT_OUT')
module.remove_commented_out_sentinel(config, field_name)
@@ -180,7 +184,7 @@ def test_remove_commented_out_sentinel_keeps_other_comments():
def test_remove_commented_out_sentinel_without_sentinel_keeps_other_comments():
field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)])
config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.')
module.remove_commented_out_sentinel(config, field_name)
@@ -192,7 +196,7 @@ def test_remove_commented_out_sentinel_without_sentinel_keeps_other_comments():
def test_remove_commented_out_sentinel_on_unknown_field_does_not_raise():
field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)])
config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.')
module.remove_commented_out_sentinel(config, 'unknown')
@@ -201,7 +205,9 @@ def test_remove_commented_out_sentinel_on_unknown_field_does_not_raise():
def test_generate_sample_configuration_does_not_raise():
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load')
flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
flexmock(module).should_receive('schema_to_sample_configuration')
flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration')
@@ -214,7 +220,9 @@ def test_generate_sample_configuration_does_not_raise():
def test_generate_sample_configuration_with_source_filename_does_not_raise():
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load')
flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
flexmock(module.load).should_receive('load_configuration')
flexmock(module.normalize).should_receive('normalize')
flexmock(module).should_receive('schema_to_sample_configuration')
@@ -229,7 +237,9 @@ def test_generate_sample_configuration_with_source_filename_does_not_raise():
def test_generate_sample_configuration_with_dry_run_does_not_write_file():
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load')
flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
flexmock(module).should_receive('schema_to_sample_configuration')
flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration')
-29
View File
@@ -15,35 +15,6 @@ def test_load_configuration_parses_contents():
assert module.load_configuration('config.yaml') == {'key': 'value'}
def test_load_configuration_replaces_constants():
builtins = flexmock(sys.modules['builtins'])
config_file = io.StringIO(
'''
constants:
key: value
key: {key}
'''
)
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == {'key': 'value'}
def test_load_configuration_replaces_complex_constants():
builtins = flexmock(sys.modules['builtins'])
config_file = io.StringIO(
'''
constants:
key:
subkey: value
key: {key}
'''
)
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == {'key': {'subkey': 'value'}}
def test_load_configuration_with_only_integer_value_does_not_raise():
builtins = flexmock(sys.modules['builtins'])
config_file = io.StringIO('33')
+24 -12
View File
@@ -4,19 +4,24 @@ from borgmatic.config import override as module
@pytest.mark.parametrize(
'value,expected_result',
'value,expected_result,option_type',
(
('thing', 'thing'),
('33', 33),
('33b', '33b'),
('true', True),
('false', False),
('[foo]', ['foo']),
('[foo, bar]', ['foo', 'bar']),
('thing', 'thing', 'string'),
('33', 33, 'integer'),
('33', '33', 'string'),
('33b', '33b', 'integer'),
('33b', '33b', 'string'),
('true', True, 'boolean'),
('false', False, 'boolean'),
('true', 'true', 'string'),
('[foo]', ['foo'], 'array'),
('[foo]', '[foo]', 'string'),
('[foo, bar]', ['foo', 'bar'], 'array'),
('[foo, bar]', '[foo, bar]', 'string'),
),
)
def test_convert_value_type_coerces_values(value, expected_result):
assert module.convert_value_type(value) == expected_result
def test_convert_value_type_coerces_values(value, expected_result, option_type):
assert module.convert_value_type(value, option_type) == expected_result
def test_apply_overrides_updates_config():
@@ -25,16 +30,23 @@ def test_apply_overrides_updates_config():
'other_section.thing=value2',
'section.nested.key=value3',
'new.foo=bar',
'new.mylist=[baz]',
'new.nonlist=[quux]',
]
config = {
'section': {'key': 'value', 'other': 'other_value'},
'other_section': {'thing': 'thing_value'},
}
schema = {
'properties': {
'new': {'properties': {'mylist': {'type': 'array'}, 'nonlist': {'type': 'string'}}}
}
}
module.apply_overrides(config, raw_overrides)
module.apply_overrides(config, schema, raw_overrides)
assert config == {
'section': {'key': 'value1', 'other': 'other_value', 'nested': {'key': 'value3'}},
'other_section': {'thing': 'value2'},
'new': {'foo': 'bar'},
'new': {'foo': 'bar', 'mylist': ['baz'], 'nonlist': '[quux]'},
}
+5 -3
View File
@@ -1,4 +1,5 @@
import io
import os
import string
import sys
@@ -244,7 +245,7 @@ def test_parse_configuration_applies_overrides():
assert logs == []
def test_parse_configuration_applies_normalization():
def test_parse_configuration_applies_normalization_after_environment_variable_interpolation():
mock_config_and_schema(
'''
location:
@@ -252,17 +253,18 @@ def test_parse_configuration_applies_normalization():
- /home
repositories:
- path: hostname.borg
- ${NO_EXIST:-user@hostname:repo}
exclude_if_present: .nobackup
'''
)
flexmock(os).should_receive('getenv').replace_with(lambda variable_name, default: default)
config, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
assert config == {
'source_directories': ['/home'],
'repositories': [{'path': 'hostname.borg'}],
'repositories': [{'path': 'ssh://user@hostname/./repo'}],
'exclude_if_present': ['.nobackup'],
}
assert logs
+2 -2
View File
@@ -151,7 +151,7 @@ def test_create_borgmatic_manifest_creates_manifest_file():
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.os).should_receive('makedirs').and_return(True)
flexmock(module.importlib_metadata).should_receive('version').and_return('1.0.0')
flexmock(module.importlib.metadata).should_receive('version').and_return('1.0.0')
flexmock(sys.modules['builtins']).should_receive('open').with_args(
'/home/user/.borgmatic/bootstrap/manifest.json', 'w'
).and_return(
@@ -172,7 +172,7 @@ def test_create_borgmatic_manifest_creates_manifest_file_with_custom_borgmatic_s
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.os).should_receive('makedirs').and_return(True)
flexmock(module.importlib_metadata).should_receive('version').and_return('1.0.0')
flexmock(module.importlib.metadata).should_receive('version').and_return('1.0.0')
flexmock(sys.modules['builtins']).should_receive('open').with_args(
'/borgmatic/bootstrap/manifest.json', 'w'
).and_return(
+160 -20
View File
@@ -193,6 +193,19 @@ def test_filter_checks_on_frequency_restains_check_with_unelapsed_frequency_and_
) == ('archives',)
def test_filter_checks_on_frequency_passes_through_empty_checks():
assert (
module.filter_checks_on_frequency(
config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=(),
force=False,
archives_check_id='1234',
)
== ()
)
def test_make_archive_filter_flags_with_default_checks_and_prefix_returns_default_flags():
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
@@ -201,6 +214,7 @@ def test_make_archive_filter_flags_with_default_checks_and_prefix_returns_defaul
'1.2.3',
{},
('repository', 'archives'),
check_arguments=flexmock(match_archives=None),
prefix='foo',
)
@@ -215,6 +229,7 @@ def test_make_archive_filter_flags_with_all_checks_and_prefix_returns_default_fl
'1.2.3',
{},
('repository', 'archives', 'extract'),
check_arguments=flexmock(match_archives=None),
prefix='foo',
)
@@ -229,6 +244,7 @@ def test_make_archive_filter_flags_with_all_checks_and_prefix_without_borg_featu
'1.2.3',
{},
('repository', 'archives', 'extract'),
check_arguments=flexmock(match_archives=None),
prefix='foo',
)
@@ -239,7 +255,9 @@ def test_make_archive_filter_flags_with_archives_check_and_last_includes_last_fl
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('archives',), check_last=3)
flags = module.make_archive_filter_flags(
'1.2.3', {}, ('archives',), check_arguments=flexmock(match_archives=None), check_last=3
)
assert flags == ('--last', '3')
@@ -248,7 +266,9 @@ def test_make_archive_filter_flags_with_data_check_and_last_includes_last_flag()
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('data',), check_last=3)
flags = module.make_archive_filter_flags(
'1.2.3', {}, ('data',), check_arguments=flexmock(match_archives=None), check_last=3
)
assert flags == ('--last', '3')
@@ -257,7 +277,9 @@ def test_make_archive_filter_flags_with_repository_check_and_last_omits_last_fla
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('repository',), check_last=3)
flags = module.make_archive_filter_flags(
'1.2.3', {}, ('repository',), check_arguments=flexmock(match_archives=None), check_last=3
)
assert flags == ()
@@ -266,7 +288,13 @@ def test_make_archive_filter_flags_with_default_checks_and_last_includes_last_fl
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('repository', 'archives'), check_last=3)
flags = module.make_archive_filter_flags(
'1.2.3',
{},
('repository', 'archives'),
check_arguments=flexmock(match_archives=None),
check_last=3,
)
assert flags == ('--last', '3')
@@ -275,7 +303,9 @@ def test_make_archive_filter_flags_with_archives_check_and_prefix_includes_match
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('archives',), prefix='foo-')
flags = module.make_archive_filter_flags(
'1.2.3', {}, ('archives',), check_arguments=flexmock(match_archives=None), prefix='foo-'
)
assert flags == ('--match-archives', 'sh:foo-*')
@@ -284,11 +314,30 @@ def test_make_archive_filter_flags_with_data_check_and_prefix_includes_match_arc
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('data',), prefix='foo-')
flags = module.make_archive_filter_flags(
'1.2.3', {}, ('data',), check_arguments=flexmock(match_archives=None), prefix='foo-'
)
assert flags == ('--match-archives', 'sh:foo-*')
def test_make_archive_filter_flags_prefers_check_arguments_match_archives_to_config_match_archives():
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
'baz-*', None, '1.2.3'
).and_return(('--match-archives', 'sh:baz-*'))
flags = module.make_archive_filter_flags(
'1.2.3',
{'match_archives': 'bar-{now}'}, # noqa: FS003
('archives',),
check_arguments=flexmock(match_archives='baz-*'),
prefix='',
)
assert flags == ('--match-archives', 'sh:baz-*')
def test_make_archive_filter_flags_with_archives_check_and_empty_prefix_uses_archive_name_format_instead():
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
@@ -296,7 +345,11 @@ def test_make_archive_filter_flags_with_archives_check_and_empty_prefix_uses_arc
).and_return(('--match-archives', 'sh:bar-*'))
flags = module.make_archive_filter_flags(
'1.2.3', {'archive_name_format': 'bar-{now}'}, ('archives',), prefix='' # noqa: FS003
'1.2.3',
{'archive_name_format': 'bar-{now}'}, # noqa: FS003
('archives',),
check_arguments=flexmock(match_archives=None),
prefix='',
)
assert flags == ('--match-archives', 'sh:bar-*')
@@ -306,7 +359,9 @@ def test_make_archive_filter_flags_with_archives_check_and_none_prefix_omits_mat
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('archives',), prefix=None)
flags = module.make_archive_filter_flags(
'1.2.3', {}, ('archives',), check_arguments=flexmock(match_archives=None), prefix=None
)
assert flags == ()
@@ -315,7 +370,9 @@ def test_make_archive_filter_flags_with_repository_check_and_prefix_omits_match_
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('repository',), prefix='foo-')
flags = module.make_archive_filter_flags(
'1.2.3', {}, ('repository',), check_arguments=flexmock(match_archives=None), prefix='foo-'
)
assert flags == ()
@@ -324,7 +381,13 @@ def test_make_archive_filter_flags_with_default_checks_and_prefix_includes_match
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('repository', 'archives'), prefix='foo-')
flags = module.make_archive_filter_flags(
'1.2.3',
{},
('repository', 'archives'),
check_arguments=flexmock(match_archives=None),
prefix='foo-',
)
assert flags == ('--match-archives', 'sh:foo-*')
@@ -607,7 +670,7 @@ def test_upgrade_check_times_renames_stale_temporary_check_path():
module.upgrade_check_times(flexmock(), flexmock())
def test_check_archives_with_progress_calls_borg_with_progress_parameter():
def test_check_archives_with_progress_passes_through_to_borg():
checks = ('repository',)
config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@@ -634,12 +697,14 @@ def test_check_archives_with_progress_calls_borg_with_progress_parameter():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=True, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
progress=True,
)
def test_check_archives_with_repair_calls_borg_with_repair_parameter():
def test_check_archives_with_repair_passes_through_to_borg():
checks = ('repository',)
config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@@ -666,8 +731,10 @@ def test_check_archives_with_repair_calls_borg_with_repair_parameter():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=True, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
repair=True,
)
@@ -701,6 +768,9 @@ def test_check_archives_calls_borg_with_parameters(checks):
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
@@ -723,6 +793,9 @@ def test_check_archives_with_json_error_raises():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
@@ -743,6 +816,9 @@ def test_check_archives_with_missing_json_keys_raises():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
@@ -769,11 +845,14 @@ def test_check_archives_with_extract_check_calls_extract_only():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
def test_check_archives_with_log_info_calls_borg_with_info_parameter():
def test_check_archives_with_log_info_passes_through_to_borg():
checks = ('repository',)
config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@@ -795,11 +874,14 @@ def test_check_archives_with_log_info_calls_borg_with_info_parameter():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
def test_check_archives_with_log_debug_calls_borg_with_debug_parameter():
def test_check_archives_with_log_debug_passes_through_to_borg():
checks = ('repository',)
config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@@ -821,6 +903,9 @@ def test_check_archives_with_log_debug_calls_borg_with_debug_parameter():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
@@ -841,6 +926,9 @@ def test_check_archives_without_any_checks_bails():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
@@ -867,12 +955,15 @@ def test_check_archives_with_local_path_calls_borg_via_local_path():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
local_path='borg1',
)
def test_check_archives_with_remote_path_calls_borg_with_remote_path_parameters():
def test_check_archives_with_remote_path_passes_through_to_borg():
checks = ('repository',)
check_last = flexmock()
config = {'check_last': check_last}
@@ -894,12 +985,15 @@ def test_check_archives_with_remote_path_calls_borg_with_remote_path_parameters(
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
remote_path='borg1',
)
def test_check_archives_with_log_json_calls_borg_with_log_json_parameters():
def test_check_archives_with_log_json_passes_through_to_borg():
checks = ('repository',)
check_last = flexmock()
config = {'check_last': check_last}
@@ -921,11 +1015,14 @@ def test_check_archives_with_log_json_calls_borg_with_log_json_parameters():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=True),
)
def test_check_archives_with_lock_wait_calls_borg_with_lock_wait_parameters():
def test_check_archives_with_lock_wait_passes_through_to_borg():
checks = ('repository',)
check_last = flexmock()
config = {'lock_wait': 5, 'check_last': check_last}
@@ -947,6 +1044,9 @@ def test_check_archives_with_lock_wait_calls_borg_with_lock_wait_parameters():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
@@ -974,11 +1074,14 @@ def test_check_archives_with_retention_prefix():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options():
def test_check_archives_with_extra_borg_options_passes_through_to_borg():
checks = ('repository',)
config = {'check_last': None, 'extra_borg_options': {'check': '--extra --options'}}
flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@@ -999,5 +1102,42 @@ def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options():
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
def test_check_archives_with_match_archives_passes_through_to_borg():
checks = ('archives',)
config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('upgrade_check_times')
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('make_archive_filter_flags').and_return(
('--match-archives', 'foo-*')
)
flexmock(module).should_receive('make_archives_check_id').and_return(None)
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module).should_receive('make_check_flags').and_return(('--match-archives', 'foo-*'))
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'check', '--match-archives', 'foo-*', 'repo'),
extra_environment=None,
).once()
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives='foo-*'
),
global_arguments=flexmock(log_json=False),
)
+83
View File
@@ -23,6 +23,16 @@ def test_run_configuration_runs_actions_for_each_repository():
assert results == expected_results
def test_run_configuration_with_skip_actions_does_not_raise():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module).should_receive('run_actions').and_return(flexmock()).and_return(flexmock())
config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}], 'skip_actions': ['compact']}
arguments = {'global': flexmock(monitoring_verbosity=1)}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_with_invalid_borg_version_errors():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module.borg_version).should_receive('local_borg_version').and_raise(ValueError)
@@ -504,6 +514,24 @@ def test_run_actions_runs_create():
assert result == (expected,)
def test_run_actions_with_skip_actions_skips_create():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.create).should_receive('run_create').never()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'create': flexmock()},
config_filename=flexmock(),
config={'repositories': [], 'skip_actions': ['create']},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_prune():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
@@ -522,6 +550,24 @@ def test_run_actions_runs_prune():
)
def test_run_actions_with_skip_actions_skips_prune():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.prune).should_receive('run_prune').never()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'prune': flexmock()},
config_filename=flexmock(),
config={'repositories': [], 'skip_actions': ['prune']},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_compact():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
@@ -540,6 +586,24 @@ def test_run_actions_runs_compact():
)
def test_run_actions_with_skip_actions_skips_compact():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.compact).should_receive('run_compact').never()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'compact': flexmock()},
config_filename=flexmock(),
config={'repositories': [], 'skip_actions': ['compact']},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_check_when_repository_enabled_for_checks():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
@@ -578,6 +642,25 @@ def test_run_actions_skips_check_when_repository_not_enabled_for_checks():
)
def test_run_actions_with_skip_actions_skips_check():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True)
flexmock(borgmatic.actions.check).should_receive('run_check').never()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'check': flexmock()},
config_filename=flexmock(),
config={'repositories': [], 'skip_actions': ['check']},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_extract():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
+3 -3
View File
@@ -2,14 +2,14 @@ from borgmatic.config import checks as module
def test_repository_enabled_for_checks_defaults_to_enabled_for_all_repositories():
enabled = module.repository_enabled_for_checks('repo.borg', consistency={})
enabled = module.repository_enabled_for_checks('repo.borg', config={})
assert enabled
def test_repository_enabled_for_checks_is_enabled_for_specified_repositories():
enabled = module.repository_enabled_for_checks(
'repo.borg', consistency={'check_repositories': ['repo.borg', 'other.borg']}
'repo.borg', config={'check_repositories': ['repo.borg', 'other.borg']}
)
assert enabled
@@ -17,7 +17,7 @@ def test_repository_enabled_for_checks_is_enabled_for_specified_repositories():
def test_repository_enabled_for_checks_is_disabled_for_other_repositories():
enabled = module.repository_enabled_for_checks(
'repo.borg', consistency={'check_repositories': ['other.borg']}
'repo.borg', config={'check_repositories': ['other.borg']}
)
assert not enabled
+58
View File
@@ -0,0 +1,58 @@
import pytest
from flexmock import flexmock
from borgmatic.config import constants as module
@pytest.mark.parametrize(
'value,expected_value',
(
('3', 3),
('0', 0),
('-3', -3),
('1234', 1234),
('true', True),
('True', True),
('false', False),
('False', False),
('thing', 'thing'),
({}, {}),
({'foo': 'bar'}, {'foo': 'bar'}),
([], []),
(['foo', 'bar'], ['foo', 'bar']),
),
)
def test_coerce_scalar_converts_value(value, expected_value):
assert module.coerce_scalar(value) == expected_value
def test_apply_constants_with_empty_constants_passes_through_value():
assert module.apply_constants(value='thing', constants={}) == 'thing'
@pytest.mark.parametrize(
'value,expected_value',
(
(None, None),
('thing', 'thing'),
('{foo}', 'bar'),
('abc{foo}', 'abcbar'),
('{foo}xyz', 'barxyz'),
('{foo}{baz}', 'barquux'),
('{int}', '3'),
('{bool}', 'True'),
(['thing', 'other'], ['thing', 'other']),
(['thing', '{foo}'], ['thing', 'bar']),
(['{foo}', '{baz}'], ['bar', 'quux']),
({'key': 'value'}, {'key': 'value'}),
({'key': '{foo}'}, {'key': 'bar'}),
(3, 3),
(True, True),
(False, False),
),
)
def test_apply_constants_makes_string_substitutions(value, expected_value):
flexmock(module).should_receive('coerce_scalar').replace_with(lambda value: value)
constants = {'foo': 'bar', 'baz': 'quux', 'int': 3, 'bool': True}
assert module.apply_constants(value, constants) == expected_value
+7 -7
View File
@@ -7,7 +7,7 @@ from borgmatic.config import generate as module
def test_schema_to_sample_configuration_generates_config_map_with_examples():
flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
flexmock(module.ruamel.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
flexmock(module).should_receive('add_comments_to_configuration_object')
schema = {
'type': 'object',
@@ -32,7 +32,7 @@ def test_schema_to_sample_configuration_generates_config_map_with_examples():
def test_schema_to_sample_configuration_generates_config_sequence_of_strings_with_example():
flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list)
flexmock(module.ruamel.yaml.comments).should_receive('CommentedSeq').replace_with(list)
flexmock(module).should_receive('add_comments_to_configuration_sequence')
schema = {'type': 'array', 'items': {'type': 'string'}, 'example': ['hi']}
@@ -42,7 +42,7 @@ def test_schema_to_sample_configuration_generates_config_sequence_of_strings_wit
def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_examples():
flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list)
flexmock(module.ruamel.yaml.comments).should_receive('CommentedSeq').replace_with(list)
flexmock(module).should_receive('add_comments_to_configuration_sequence')
flexmock(module).should_receive('add_comments_to_configuration_object')
schema = {
@@ -71,7 +71,7 @@ def test_merge_source_configuration_into_destination_inserts_map_fields():
destination_config = {'foo': 'dest1', 'bar': 'dest2'}
source_config = {'foo': 'source1', 'baz': 'source2'}
flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config)
@@ -82,7 +82,7 @@ def test_merge_source_configuration_into_destination_inserts_nested_map_fields()
destination_config = {'foo': {'first': 'dest1', 'second': 'dest2'}, 'bar': 'dest3'}
source_config = {'foo': {'first': 'source1'}}
flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config)
@@ -93,7 +93,7 @@ def test_merge_source_configuration_into_destination_inserts_sequence_fields():
destination_config = {'foo': ['dest1', 'dest2'], 'bar': ['dest3'], 'baz': ['dest4']}
source_config = {'foo': ['source1'], 'bar': ['source2', 'source3']}
flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config)
@@ -108,7 +108,7 @@ def test_merge_source_configuration_into_destination_inserts_sequence_of_maps():
destination_config = {'foo': [{'first': 'dest1', 'second': 'dest2'}], 'bar': 'dest3'}
source_config = {'foo': [{'first': 'source1'}, {'other': 'source2'}]}
flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config)
+41 -10
View File
@@ -44,6 +44,24 @@ def test_set_values_with_multiple_keys_updates_hierarchy():
assert config == {'option': {'key': 'value', 'other': 'other_value'}}
@pytest.mark.parametrize(
'schema,option_keys,expected_type',
(
({'properties': {'foo': {'type': 'array'}}}, ('foo',), 'array'),
(
{'properties': {'foo': {'properties': {'bar': {'type': 'array'}}}}},
('foo', 'bar'),
'array',
),
({'properties': {'foo': {'type': 'array'}}}, ('other',), None),
({'properties': {'foo': {'description': 'stuff'}}}, ('foo',), None),
({}, ('foo',), None),
),
)
def test_type_for_option_grabs_type_if_found_in_schema(schema, option_keys, expected_type):
assert module.type_for_option(schema, option_keys) == expected_type
@pytest.mark.parametrize(
'key,expected_key',
(
@@ -63,51 +81,64 @@ def test_strip_section_names_passes_through_key_without_section_name(key, expect
def test_parse_overrides_splits_keys_and_values():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value)
flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').replace_with(
lambda value, option_type: value
)
raw_overrides = ['option.my_option=value1', 'other_option=value2']
expected_result = (
(('option', 'my_option'), 'value1'),
(('other_option'), 'value2'),
)
module.parse_overrides(raw_overrides) == expected_result
module.parse_overrides(raw_overrides, schema={}) == expected_result
def test_parse_overrides_allows_value_with_equal_sign():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value)
flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').replace_with(
lambda value, option_type: value
)
raw_overrides = ['option=this===value']
expected_result = ((('option',), 'this===value'),)
module.parse_overrides(raw_overrides) == expected_result
module.parse_overrides(raw_overrides, schema={}) == expected_result
def test_parse_overrides_raises_on_missing_equal_sign():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value)
flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').replace_with(
lambda value, option_type: value
)
raw_overrides = ['option']
with pytest.raises(ValueError):
module.parse_overrides(raw_overrides)
module.parse_overrides(raw_overrides, schema={})
def test_parse_overrides_raises_on_invalid_override_value():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').and_raise(ruamel.yaml.parser.ParserError)
raw_overrides = ['option=[in valid]']
with pytest.raises(ValueError):
module.parse_overrides(raw_overrides)
module.parse_overrides(raw_overrides, schema={})
def test_parse_overrides_allows_value_with_single_key():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value)
flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').replace_with(
lambda value, option_type: value
)
raw_overrides = ['option=value']
expected_result = ((('option',), 'value'),)
module.parse_overrides(raw_overrides) == expected_result
module.parse_overrides(raw_overrides, schema={}) == expected_result
def test_parse_overrides_handles_empty_overrides():
module.parse_overrides(raw_overrides=None) == ()
module.parse_overrides(raw_overrides=None, schema={}) == ()
+2 -2
View File
@@ -1,5 +1,5 @@
[tox]
env_list = py37,py38,py39,py310,py311
env_list = py38,py39,py310,py311,py312
skip_missing_interpreters = True
package = editable
min_version = 4.0
@@ -13,7 +13,7 @@ whitelist_externals =
passenv = COVERAGE_FILE
commands =
pytest {posargs}
py38,py39,py310,py311: black --check .
black --check .
isort --check-only --settings-path setup.cfg .
flake8 borgmatic tests
codespell