declarative-data-backup 0.2.1__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/PKG-INFO +22 -11
  2. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/README.md +21 -10
  3. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/pyproject.toml +1 -1
  4. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/backup.py +7 -0
  5. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/borg/borg.py +13 -7
  6. declarative_data_backup-0.3.1/src/declarative_data_backup/borg/borg_cli.py +164 -0
  7. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/environment.py +10 -0
  8. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/PKG-INFO +22 -11
  9. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/tests/test_backup.py +4 -0
  10. declarative_data_backup-0.2.1/src/declarative_data_backup/borg/borg_cli.py +0 -69
  11. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/setup.cfg +0 -0
  12. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/__init__.py +0 -0
  13. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/__main__.py +0 -0
  14. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/borg/__init__.py +0 -0
  15. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/borg/archive_naming.py +0 -0
  16. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/__init__.py +0 -0
  17. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/__init__.py +0 -0
  18. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/models.py +0 -0
  19. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/utils.py +0 -0
  20. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/yaml_parser.py +0 -0
  21. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/backup_config/yaml_raw_loader.py +0 -0
  22. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/config/logging.py +0 -0
  23. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup/errors.py +0 -0
  24. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/SOURCES.txt +0 -0
  25. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/dependency_links.txt +0 -0
  26. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/requires.txt +0 -0
  27. {declarative_data_backup-0.2.1 → declarative_data_backup-0.3.1}/src/declarative_data_backup.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: declarative-data-backup
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Declarative Borg-based data backups
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -149,6 +149,8 @@ Optional variables:
149
149
  | --- | --- |
150
150
  | `BORG_REMOTE_PATH` | Remote Borg executable path, passed as Borg's `--remote-path`. Leave unset to use Borg's default. |
151
151
  | `BORG_EXIT_CODES` | Borg exit-code mode: `legacy` or `modern`. |
152
+ | `BACKUP_PROGRESS` | Set to `true` to log the latest Borg progress every 30 seconds during archive creation. Defaults to `false`. |
153
+ | `BACKUP_VERBOSE` | Set to `true` to show Borg's per-file create and per-archive prune output. Defaults to `false` for concise logs. |
152
154
 
153
155
  Provide credentials through a secret manager or protected files; do not put
154
156
  passphrases, private keys, or credentials in the YAML file, source control, or
@@ -161,7 +163,15 @@ python -m declarative_data_backup
161
163
 
162
164
  The command exits with status `0` on success, nonzero on configuration or Borg
163
165
  failure, and `130` when interrupted. Borg's exit code `1` indicates warnings:
164
- the run fails and pruning is skipped.
166
+ the run fails and pruning is skipped. Logs include group and source start/end
167
+ messages, while Borg's per-file and per-archive output is disabled by default.
168
+ Set `BACKUP_VERBOSE=true` for detailed Borg output or `BACKUP_PROGRESS=true`
169
+ for live progress during archive creation; either can increase log volume.
170
+ Progress is logged as plain status lines, not a terminal progress bar. Each
171
+ line includes the group, source, elapsed time, and latest reported file/byte
172
+ counters or Borg phase. Unchanged counters do not prove forward progress;
173
+ before Borg reports a phase, the status only indicates that the process is
174
+ running. Warnings and statistics are not throttled.
165
175
 
166
176
  ## Configuration reference
167
177
 
@@ -289,23 +299,24 @@ container integration test.
289
299
 
290
300
  ## Testing
291
301
 
292
- Run the unit tests:
302
+ Run the unit tests, integration tests, or both:
293
303
 
294
304
  ```shell
295
- uv run pytest -m "not integration"
305
+ make test-unit
306
+ make test-integration
307
+ make test
296
308
  ```
297
309
 
298
- The opt-in local Borg integration test uses a temporary encrypted repository:
310
+ The integration target opts in to both local Borg tests (which use temporary
311
+ encrypted repositories) and the Docker SSH test (which uses disposable
312
+ containers and a temporary repository). It does not use production credentials
313
+ or BorgBase. Docker must be installed and its daemon available.
314
+
315
+ To run either integration suite individually:
299
316
 
300
317
  ```shell
301
318
  RUN_BORG_LOCAL_INTEGRATION=1 uv run pytest -m integration \
302
319
  tests/integration/test_local_borg_integration.py
303
- ```
304
-
305
- The opt-in Docker SSH integration test uses disposable containers and a
306
- temporary repository; it does not use production credentials or BorgBase:
307
-
308
- ```shell
309
320
  RUN_BORG_DOCKER_INTEGRATION=1 uv run pytest -m integration \
310
321
  tests/integration/test_docker_integration.py
311
322
  ```
@@ -141,6 +141,8 @@ Optional variables:
141
141
  | --- | --- |
142
142
  | `BORG_REMOTE_PATH` | Remote Borg executable path, passed as Borg's `--remote-path`. Leave unset to use Borg's default. |
143
143
  | `BORG_EXIT_CODES` | Borg exit-code mode: `legacy` or `modern`. |
144
+ | `BACKUP_PROGRESS` | Set to `true` to log the latest Borg progress every 30 seconds during archive creation. Defaults to `false`. |
145
+ | `BACKUP_VERBOSE` | Set to `true` to show Borg's per-file create and per-archive prune output. Defaults to `false` for concise logs. |
144
146
 
145
147
  Provide credentials through a secret manager or protected files; do not put
146
148
  passphrases, private keys, or credentials in the YAML file, source control, or
@@ -153,7 +155,15 @@ python -m declarative_data_backup
153
155
 
154
156
  The command exits with status `0` on success, nonzero on configuration or Borg
155
157
  failure, and `130` when interrupted. Borg's exit code `1` indicates warnings:
156
- the run fails and pruning is skipped.
158
+ the run fails and pruning is skipped. Logs include group and source start/end
159
+ messages, while Borg's per-file and per-archive output is disabled by default.
160
+ Set `BACKUP_VERBOSE=true` for detailed Borg output or `BACKUP_PROGRESS=true`
161
+ for live progress during archive creation; either can increase log volume.
162
+ Progress is logged as plain status lines, not a terminal progress bar. Each
163
+ line includes the group, source, elapsed time, and latest reported file/byte
164
+ counters or Borg phase. Unchanged counters do not prove forward progress;
165
+ before Borg reports a phase, the status only indicates that the process is
166
+ running. Warnings and statistics are not throttled.
157
167
 
158
168
  ## Configuration reference
159
169
 
@@ -281,23 +291,24 @@ container integration test.
281
291
 
282
292
  ## Testing
283
293
 
284
- Run the unit tests:
294
+ Run the unit tests, integration tests, or both:
285
295
 
286
296
  ```shell
287
- uv run pytest -m "not integration"
297
+ make test-unit
298
+ make test-integration
299
+ make test
288
300
  ```
289
301
 
290
- The opt-in local Borg integration test uses a temporary encrypted repository:
302
+ The integration target opts in to both local Borg tests (which use temporary
303
+ encrypted repositories) and the Docker SSH test (which uses disposable
304
+ containers and a temporary repository). It does not use production credentials
305
+ or BorgBase. Docker must be installed and its daemon available.
306
+
307
+ To run either integration suite individually:
291
308
 
292
309
  ```shell
293
310
  RUN_BORG_LOCAL_INTEGRATION=1 uv run pytest -m integration \
294
311
  tests/integration/test_local_borg_integration.py
295
- ```
296
-
297
- The opt-in Docker SSH integration test uses disposable containers and a
298
- temporary repository; it does not use production credentials or BorgBase:
299
-
300
- ```shell
301
312
  RUN_BORG_DOCKER_INTEGRATION=1 uv run pytest -m integration \
302
313
  tests/integration/test_docker_integration.py
303
314
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "declarative-data-backup"
7
- version = "0.2.1"
7
+ version = "0.3.1"
8
8
  description = "Declarative Borg-based data backups"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -70,8 +70,14 @@ def run_backup(
70
70
  run_id,
71
71
  definition.excludes,
72
72
  )
73
+ logger.info("Backup group '%s' completed.", definition.name)
73
74
 
74
75
  for definition in definitions:
76
+ logger.info(
77
+ "Starting retention pruning for group '%s' (%d sources).",
78
+ definition.name,
79
+ len(definition.sources),
80
+ )
75
81
  for folder in definition.sources:
76
82
  prune(
77
83
  folder,
@@ -79,6 +85,7 @@ def run_backup(
79
85
  definition.name,
80
86
  settings,
81
87
  )
88
+ logger.info("Retention pruning for group '%s' completed.", definition.name)
82
89
 
83
90
 
84
91
  def main(environ: Mapping[str, str] | None = None) -> int:
@@ -41,11 +41,14 @@ def backup_folder(
41
41
  logger.info("Starting backup for %s as archive %s.", context, name)
42
42
  args = [
43
43
  "create",
44
- "--verbose",
45
44
  "--stats",
46
45
  "--compression",
47
46
  compression,
48
47
  ]
48
+ if settings.backup_verbose:
49
+ args.append("--verbose")
50
+ if settings.backup_progress:
51
+ args.append("--progress")
49
52
  for pattern in excludes:
50
53
  args.extend(("--exclude", pattern))
51
54
  args.extend((f"{settings.borg_repo}::{name}", str(folder)))
@@ -85,17 +88,20 @@ def prune(
85
88
  retention_days,
86
89
  )
87
90
  try:
88
- run_borg(
89
- [
90
- "prune",
91
- "--verbose",
92
- "--list",
91
+ args = ["prune"]
92
+ if settings.backup_verbose:
93
+ args.extend(("--verbose", "--list"))
94
+ args.extend(
95
+ (
93
96
  "--glob-archives",
94
97
  selector,
95
98
  "--keep-daily",
96
99
  str(retention_days),
97
100
  settings.borg_repo,
98
- ],
101
+ )
102
+ )
103
+ run_borg(
104
+ args,
99
105
  settings,
100
106
  "prune",
101
107
  context,
@@ -0,0 +1,164 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import selectors
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import time
9
+
10
+ from ..environment import Environment
11
+ from ..errors import BorgWarningError, fail
12
+
13
+ logger = logging.getLogger(__name__)
14
+ PROGRESS_INTERVAL_SECONDS = 30
15
+
16
+
17
+ def run_with_progress(
18
+ command: list[str], environment: dict[str, str], context: str
19
+ ) -> int:
20
+ started_at = time.monotonic()
21
+ next_report = started_at + PROGRESS_INTERVAL_SECONDS
22
+ latest_progress = "Waiting for Borg progress (process running)."
23
+
24
+ def handle_line(line: bytes) -> None:
25
+ nonlocal latest_progress
26
+ text = line.decode("utf-8", errors="replace")
27
+ try:
28
+ event = json.loads(text)
29
+ except json.JSONDecodeError:
30
+ print(text, file=sys.stderr, flush=True)
31
+ return
32
+ if not isinstance(event, dict):
33
+ print(text, file=sys.stderr, flush=True)
34
+ return
35
+ kind = event.get("type")
36
+ if kind in ("archive_progress", "progress_message", "progress_percent"):
37
+ if event.get("finished"):
38
+ return
39
+ if kind == "archive_progress":
40
+ latest_progress = (
41
+ f"files={event.get('nfiles')} "
42
+ f"original_bytes={event.get('original_size')} "
43
+ f"compressed_bytes={event.get('compressed_size')} "
44
+ f"deduplicated_bytes={event.get('deduplicated_size')}"
45
+ )
46
+ else:
47
+ latest_progress = str(event.get("message", text))
48
+ return
49
+ if kind == "log_message":
50
+ print(event.get("message", text), file=sys.stderr, flush=True)
51
+ else:
52
+ print(text, file=sys.stderr, flush=True)
53
+
54
+ with subprocess.Popen(
55
+ command,
56
+ env=environment,
57
+ stderr=subprocess.PIPE,
58
+ ) as process:
59
+ assert process.stderr is not None
60
+ try:
61
+ with selectors.DefaultSelector() as selector:
62
+ selector.register(process.stderr, selectors.EVENT_READ)
63
+ pending = b""
64
+ while selector.get_map():
65
+ for key, _ in selector.select(
66
+ max(0, next_report - time.monotonic())
67
+ ):
68
+ chunk = os.read(key.fd, 65536)
69
+ if not chunk:
70
+ selector.unregister(key.fileobj)
71
+ if pending:
72
+ handle_line(pending)
73
+ break
74
+ pending += chunk
75
+ while b"\n" in pending:
76
+ line, pending = pending.split(b"\n", 1)
77
+ handle_line(line)
78
+ now = time.monotonic()
79
+ if now >= next_report and selector.get_map():
80
+ logger.info(
81
+ "Borg progress for %s (elapsed_seconds=%.0f; "
82
+ "latest reported: %s).",
83
+ context,
84
+ now - started_at,
85
+ latest_progress,
86
+ )
87
+ next_report = now + PROGRESS_INTERVAL_SECONDS
88
+ return process.wait()
89
+ except BaseException:
90
+ process.terminate()
91
+ try:
92
+ process.wait(timeout=10)
93
+ except subprocess.TimeoutExpired:
94
+ process.kill()
95
+ process.wait()
96
+ raise
97
+
98
+
99
+ def ensure_borg_binary() -> None:
100
+ if shutil.which("borg") is None:
101
+ fail("Required executable 'borg' was not found on PATH.")
102
+
103
+
104
+ def borg_environment(settings: Environment) -> dict[str, str]:
105
+ environment = os.environ.copy()
106
+ environment.update(
107
+ {
108
+ "BORG_REPO": settings.borg_repo,
109
+ "BORG_BASE_DIR": str(settings.borg_base_dir),
110
+ "BORG_KEY_FILE": str(settings.key_file),
111
+ "BORG_RSH": settings.borg_rsh,
112
+ "BORG_PASSCOMMAND": settings.borg_passcommand,
113
+ }
114
+ )
115
+ if settings.borg_exit_codes is not None:
116
+ environment["BORG_EXIT_CODES"] = settings.borg_exit_codes
117
+ return environment
118
+
119
+
120
+ def run_borg(
121
+ args: list[str],
122
+ settings: Environment,
123
+ operation: str,
124
+ context: str,
125
+ ) -> None:
126
+ logger.info("Running Borg %s for %s.", operation, context)
127
+ command = ["borg"]
128
+ remote_path = settings.borg_remote_path
129
+ if remote_path is not None:
130
+ if not remote_path.strip() or "\0" in remote_path:
131
+ fail("BORG_REMOTE_PATH must be a nonempty Borg executable path.")
132
+ command.extend(("--remote-path", remote_path))
133
+ command.extend(args)
134
+
135
+ try:
136
+ if settings.backup_progress and operation == "create":
137
+ command.insert(1, "--log-json")
138
+ returncode = run_with_progress(
139
+ command,
140
+ borg_environment(settings),
141
+ context,
142
+ )
143
+ else:
144
+ returncode = subprocess.run(
145
+ command,
146
+ env=borg_environment(settings),
147
+ check=False,
148
+ ).returncode
149
+ except FileNotFoundError:
150
+ fail("Required executable 'borg' could not be started.")
151
+ except OSError as exc:
152
+ fail(
153
+ f"Unable to start Borg {operation} for {context}: {exc.strerror or 'I/O error'}."
154
+ )
155
+
156
+ if returncode == 0:
157
+ return
158
+
159
+ if returncode == 1:
160
+ message = f"Borg {operation} reported warnings for {context} (exit code 1)."
161
+ logger.warning("%s", message)
162
+ raise BorgWarningError(message)
163
+
164
+ fail(f"Borg {operation} failed for {context} (exit code {returncode}).")
@@ -17,6 +17,8 @@ class Environment:
17
17
  borg_passcommand: str
18
18
  borg_remote_path: str | None
19
19
  borg_exit_codes: str | None
20
+ backup_progress: bool
21
+ backup_verbose: bool
20
22
 
21
23
  @classmethod
22
24
  def from_env(
@@ -46,6 +48,12 @@ class Environment:
46
48
  borg_exit_codes = source.get("BORG_EXIT_CODES")
47
49
  if borg_exit_codes not in (None, "legacy", "modern"):
48
50
  fail("BORG_EXIT_CODES must be either 'legacy' or 'modern'.")
51
+ backup_progress_value = source.get("BACKUP_PROGRESS", "false").lower()
52
+ if backup_progress_value not in ("true", "false"):
53
+ fail("BACKUP_PROGRESS must be either 'true' or 'false'.")
54
+ backup_verbose_value = source.get("BACKUP_VERBOSE", "false").lower()
55
+ if backup_verbose_value not in ("true", "false"):
56
+ fail("BACKUP_VERBOSE must be either 'true' or 'false'.")
49
57
 
50
58
  try:
51
59
  server_root = required_path("SERVER_ROOT").resolve(strict=True)
@@ -62,6 +70,8 @@ class Environment:
62
70
  borg_passcommand=required("BORG_PASSCOMMAND"),
63
71
  borg_remote_path=source.get("BORG_REMOTE_PATH"),
64
72
  borg_exit_codes=borg_exit_codes,
73
+ backup_progress=backup_progress_value == "true",
74
+ backup_verbose=backup_verbose_value == "true",
65
75
  )
66
76
 
67
77
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: declarative-data-backup
3
- Version: 0.2.1
3
+ Version: 0.3.1
4
4
  Summary: Declarative Borg-based data backups
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -149,6 +149,8 @@ Optional variables:
149
149
  | --- | --- |
150
150
  | `BORG_REMOTE_PATH` | Remote Borg executable path, passed as Borg's `--remote-path`. Leave unset to use Borg's default. |
151
151
  | `BORG_EXIT_CODES` | Borg exit-code mode: `legacy` or `modern`. |
152
+ | `BACKUP_PROGRESS` | Set to `true` to log the latest Borg progress every 30 seconds during archive creation. Defaults to `false`. |
153
+ | `BACKUP_VERBOSE` | Set to `true` to show Borg's per-file create and per-archive prune output. Defaults to `false` for concise logs. |
152
154
 
153
155
  Provide credentials through a secret manager or protected files; do not put
154
156
  passphrases, private keys, or credentials in the YAML file, source control, or
@@ -161,7 +163,15 @@ python -m declarative_data_backup
161
163
 
162
164
  The command exits with status `0` on success, nonzero on configuration or Borg
163
165
  failure, and `130` when interrupted. Borg's exit code `1` indicates warnings:
164
- the run fails and pruning is skipped.
166
+ the run fails and pruning is skipped. Logs include group and source start/end
167
+ messages, while Borg's per-file and per-archive output is disabled by default.
168
+ Set `BACKUP_VERBOSE=true` for detailed Borg output or `BACKUP_PROGRESS=true`
169
+ for live progress during archive creation; either can increase log volume.
170
+ Progress is logged as plain status lines, not a terminal progress bar. Each
171
+ line includes the group, source, elapsed time, and latest reported file/byte
172
+ counters or Borg phase. Unchanged counters do not prove forward progress;
173
+ before Borg reports a phase, the status only indicates that the process is
174
+ running. Warnings and statistics are not throttled.
165
175
 
166
176
  ## Configuration reference
167
177
 
@@ -289,23 +299,24 @@ container integration test.
289
299
 
290
300
  ## Testing
291
301
 
292
- Run the unit tests:
302
+ Run the unit tests, integration tests, or both:
293
303
 
294
304
  ```shell
295
- uv run pytest -m "not integration"
305
+ make test-unit
306
+ make test-integration
307
+ make test
296
308
  ```
297
309
 
298
- The opt-in local Borg integration test uses a temporary encrypted repository:
310
+ The integration target opts in to both local Borg tests (which use temporary
311
+ encrypted repositories) and the Docker SSH test (which uses disposable
312
+ containers and a temporary repository). It does not use production credentials
313
+ or BorgBase. Docker must be installed and its daemon available.
314
+
315
+ To run either integration suite individually:
299
316
 
300
317
  ```shell
301
318
  RUN_BORG_LOCAL_INTEGRATION=1 uv run pytest -m integration \
302
319
  tests/integration/test_local_borg_integration.py
303
- ```
304
-
305
- The opt-in Docker SSH integration test uses disposable containers and a
306
- temporary repository; it does not use production credentials or BorgBase:
307
-
308
- ```shell
309
320
  RUN_BORG_DOCKER_INTEGRATION=1 uv run pytest -m integration \
310
321
  tests/integration/test_docker_integration.py
311
322
  ```
@@ -29,6 +29,7 @@ def _configure_preflight(monkeypatch):
29
29
  def test_main_runs_all_creates_before_any_prune(
30
30
  settings_factory,
31
31
  monkeypatch,
32
+ caplog,
32
33
  ):
33
34
  settings = settings_factory()
34
35
  media = settings.server_root / "media"
@@ -62,6 +63,7 @@ def test_main_runs_all_creates_before_any_prune(
62
63
  monkeypatch.setattr(
63
64
  backup.uuid, "uuid4", lambda: type("UUID", (), {"hex": "a" * 32})()
64
65
  )
66
+ caplog.set_level(logging.INFO)
65
67
 
66
68
  assert backup.main(_settings_environment(settings)) == 0
67
69
  assert [event[0] for event in events] == [
@@ -73,6 +75,8 @@ def test_main_runs_all_creates_before_any_prune(
73
75
  assert events[0][3] == "zstd,5"
74
76
  assert events[1][3] == "auto,zstd,22"
75
77
  assert events[2][3] == 30
78
+ assert "Backup group 'media_backup' completed." in caplog.text
79
+ assert "Retention pruning for group 'media_backup' completed." in caplog.text
76
80
 
77
81
 
78
82
  def test_main_warning_aborts_remaining_creates_and_all_pruning(
@@ -1,69 +0,0 @@
1
- import logging
2
- import os
3
- import shutil
4
- import subprocess
5
-
6
- from ..environment import Environment
7
- from ..errors import BorgWarningError, fail
8
-
9
- logger = logging.getLogger(__name__)
10
-
11
-
12
- def ensure_borg_binary() -> None:
13
- if shutil.which("borg") is None:
14
- fail("Required executable 'borg' was not found on PATH.")
15
-
16
-
17
- def borg_environment(settings: Environment) -> dict[str, str]:
18
- environment = os.environ.copy()
19
- environment.update(
20
- {
21
- "BORG_REPO": settings.borg_repo,
22
- "BORG_BASE_DIR": str(settings.borg_base_dir),
23
- "BORG_KEY_FILE": str(settings.key_file),
24
- "BORG_RSH": settings.borg_rsh,
25
- "BORG_PASSCOMMAND": settings.borg_passcommand,
26
- }
27
- )
28
- if settings.borg_exit_codes is not None:
29
- environment["BORG_EXIT_CODES"] = settings.borg_exit_codes
30
- return environment
31
-
32
-
33
- def run_borg(
34
- args: list[str],
35
- settings: Environment,
36
- operation: str,
37
- context: str,
38
- ) -> None:
39
- logger.info("Running Borg %s for %s.", operation, context)
40
- command = ["borg"]
41
- remote_path = settings.borg_remote_path
42
- if remote_path is not None:
43
- if not remote_path.strip() or "\0" in remote_path:
44
- fail("BORG_REMOTE_PATH must be a nonempty Borg executable path.")
45
- command.extend(("--remote-path", remote_path))
46
- command.extend(args)
47
-
48
- try:
49
- result = subprocess.run(
50
- command,
51
- env=borg_environment(settings),
52
- check=False,
53
- )
54
- except FileNotFoundError:
55
- fail("Required executable 'borg' could not be started.")
56
- except OSError as exc:
57
- fail(
58
- f"Unable to start Borg {operation} for {context}: {exc.strerror or 'I/O error'}."
59
- )
60
-
61
- if result.returncode == 0:
62
- return
63
-
64
- if result.returncode == 1:
65
- message = f"Borg {operation} reported warnings for {context} (exit code 1)."
66
- logger.warning("%s", message)
67
- raise BorgWarningError(message)
68
-
69
- fail(f"Borg {operation} failed for {context} (exit code {result.returncode}).")