declarative-data-backup 0.3.1__tar.gz → 0.4.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 (26) hide show
  1. {declarative_data_backup-0.3.1/src/declarative_data_backup.egg-info → declarative_data_backup-0.4.1}/PKG-INFO +17 -5
  2. declarative_data_backup-0.3.1/PKG-INFO → declarative_data_backup-0.4.1/README.md +16 -12
  3. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/pyproject.toml +1 -1
  4. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/backup.py +73 -18
  5. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/borg/borg_cli.py +76 -47
  6. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/errors.py +6 -0
  7. declarative_data_backup-0.3.1/README.md → declarative_data_backup-0.4.1/src/declarative_data_backup.egg-info/PKG-INFO +24 -4
  8. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/tests/test_backup.py +10 -4
  9. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/setup.cfg +0 -0
  10. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/__init__.py +0 -0
  11. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/__main__.py +0 -0
  12. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/borg/__init__.py +0 -0
  13. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/borg/archive_naming.py +0 -0
  14. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/borg/borg.py +0 -0
  15. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/__init__.py +0 -0
  16. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/__init__.py +0 -0
  17. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/models.py +0 -0
  18. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/utils.py +0 -0
  19. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/yaml_parser.py +0 -0
  20. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/backup_config/yaml_raw_loader.py +0 -0
  21. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/config/logging.py +0 -0
  22. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup/environment.py +0 -0
  23. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup.egg-info/SOURCES.txt +0 -0
  24. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup.egg-info/dependency_links.txt +0 -0
  25. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.1}/src/declarative_data_backup.egg-info/requires.txt +0 -0
  26. {declarative_data_backup-0.3.1 → declarative_data_backup-0.4.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.3.1
3
+ Version: 0.4.1
4
4
  Summary: Declarative Borg-based data backups
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -173,6 +173,16 @@ counters or Borg phase. Unchanged counters do not prove forward progress;
173
173
  before Borg reports a phase, the status only indicates that the process is
174
174
  running. Warnings and statistics are not throttled.
175
175
 
176
+ On SIGTERM (including normal Kubernetes pod termination) or Ctrl+C, the
177
+ wrapper asks Borg to stop and waits up to 20 seconds for cleanup before
178
+ releasing its coordination lock. No further sources or pruning are started.
179
+ The exit status is 143 for SIGTERM or 130 for Ctrl+C. Repeated termination
180
+ signals are ignored during cleanup. If Borg does not stop within the timeout,
181
+ its process group is killed and an error warns that Borg locks may remain.
182
+ Use a pod termination grace period of at least 30 seconds (60 recommended);
183
+ forced deletion or SIGKILL cannot be handled gracefully. Never break Borg
184
+ locks without first confirming that no repository operation is still running.
185
+
176
186
  ## Configuration reference
177
187
 
178
188
  The top level must be a nonempty YAML mapping of backup group names to group
@@ -236,10 +246,12 @@ The stable source series is derived from the canonical source path and includes
236
246
  a SHA-256 suffix. Borg receives the canonical absolute path as the source, so
237
247
  the original directory hierarchy is retained inside the archive.
238
248
 
239
- The application creates all configured archives before pruning any source.
240
- If a create fails or Borg reports warnings, the run stops and no pruning is
241
- performed. Retries use a new run UUID and may create another archive for that
242
- day; archives are never overwritten to handle a collision.
249
+ The application attempts every configured backup group before pruning any
250
+ source. If a group has a create failure or Borg reports warnings, pruning is
251
+ skipped for that group, but other groups that completed successfully are still
252
+ pruned. Failures are reported and make the overall run fail. Retries use a new
253
+ run UUID and may create another archive for that day; archives are never
254
+ overwritten to handle a collision.
243
255
 
244
256
  `--keep-daily N` retains the latest archive in up to `N` daily buckets. It does
245
257
  not guarantee `N` successful calendar days of recovery if scheduled runs fail
@@ -1,11 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: declarative-data-backup
3
- Version: 0.3.1
4
- Summary: Declarative Borg-based data backups
5
- Requires-Python: >=3.11
6
- Description-Content-Type: text/markdown
7
- Requires-Dist: PyYAML<7,>=6
8
-
9
1
  # Declarative Data Backup
10
2
 
11
3
  Declarative Data Backup creates encrypted Borg archives for configured source
@@ -173,6 +165,16 @@ counters or Borg phase. Unchanged counters do not prove forward progress;
173
165
  before Borg reports a phase, the status only indicates that the process is
174
166
  running. Warnings and statistics are not throttled.
175
167
 
168
+ On SIGTERM (including normal Kubernetes pod termination) or Ctrl+C, the
169
+ wrapper asks Borg to stop and waits up to 20 seconds for cleanup before
170
+ releasing its coordination lock. No further sources or pruning are started.
171
+ The exit status is 143 for SIGTERM or 130 for Ctrl+C. Repeated termination
172
+ signals are ignored during cleanup. If Borg does not stop within the timeout,
173
+ its process group is killed and an error warns that Borg locks may remain.
174
+ Use a pod termination grace period of at least 30 seconds (60 recommended);
175
+ forced deletion or SIGKILL cannot be handled gracefully. Never break Borg
176
+ locks without first confirming that no repository operation is still running.
177
+
176
178
  ## Configuration reference
177
179
 
178
180
  The top level must be a nonempty YAML mapping of backup group names to group
@@ -236,10 +238,12 @@ The stable source series is derived from the canonical source path and includes
236
238
  a SHA-256 suffix. Borg receives the canonical absolute path as the source, so
237
239
  the original directory hierarchy is retained inside the archive.
238
240
 
239
- The application creates all configured archives before pruning any source.
240
- If a create fails or Borg reports warnings, the run stops and no pruning is
241
- performed. Retries use a new run UUID and may create another archive for that
242
- day; archives are never overwritten to handle a collision.
241
+ The application attempts every configured backup group before pruning any
242
+ source. If a group has a create failure or Borg reports warnings, pruning is
243
+ skipped for that group, but other groups that completed successfully are still
244
+ pruned. Failures are reported and make the overall run fail. Retries use a new
245
+ run UUID and may create another archive for that day; archives are never
246
+ overwritten to handle a collision.
243
247
 
244
248
  `--keep-daily N` retains the latest archive in up to `N` daily buckets. It does
245
249
  not guarantee `N` successful calendar days of recovery if scheduled runs fail
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "declarative-data-backup"
7
- version = "0.3.1"
7
+ version = "0.4.1"
8
8
  description = "Declarative Borg-based data backups"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,8 +1,10 @@
1
1
  import logging
2
2
  import os
3
+ import signal
3
4
  import uuid
4
5
  from collections.abc import Mapping
5
6
  from datetime import UTC, datetime
7
+ from types import FrameType
6
8
 
7
9
  from .borg import (
8
10
  backup_folder,
@@ -14,7 +16,7 @@ from .borg.borg_cli import ensure_borg_binary
14
16
  from .config import configure_logging, load_yaml, parse_backup_definitions
15
17
  from .config.backup_config.models import BackupDefinition
16
18
  from .environment import Environment
17
- from .errors import BackupError, BorgWarningError
19
+ from .errors import BackupError, BackupInterrupted, BorgWarningError
18
20
 
19
21
  logger = logging.getLogger(__name__)
20
22
 
@@ -54,42 +56,86 @@ def run_backup(
54
56
  run_id = uuid.uuid4().hex
55
57
  with repository_lock(settings.borg_base_dir / "backup.lock"):
56
58
  created_at = datetime.now(UTC)
59
+ successful_definitions = []
60
+ failures: list[BackupError] = []
57
61
  for definition in definitions:
58
62
  logger.info(
59
63
  "Starting backup group '%s' (%d sources).",
60
64
  definition.name,
61
65
  len(definition.sources),
62
66
  )
63
- for folder in definition.sources:
64
- backup_folder(
65
- folder,
66
- definition.compression,
67
+ try:
68
+ for folder in definition.sources:
69
+ backup_folder(
70
+ folder,
71
+ definition.compression,
72
+ definition.name,
73
+ settings,
74
+ created_at,
75
+ run_id,
76
+ definition.excludes,
77
+ )
78
+ except BackupError as exc:
79
+ logger.error(
80
+ "Backup group '%s' failed; retention pruning will be skipped "
81
+ "for this group: %s",
67
82
  definition.name,
68
- settings,
69
- created_at,
70
- run_id,
71
- definition.excludes,
83
+ exc,
72
84
  )
85
+ failures.append(exc)
86
+ continue
87
+
73
88
  logger.info("Backup group '%s' completed.", definition.name)
89
+ successful_definitions.append(definition)
74
90
 
75
- for definition in definitions:
91
+ for definition in successful_definitions:
76
92
  logger.info(
77
93
  "Starting retention pruning for group '%s' (%d sources).",
78
94
  definition.name,
79
95
  len(definition.sources),
80
96
  )
97
+ pruning_succeeded = True
81
98
  for folder in definition.sources:
82
- prune(
83
- folder,
84
- definition.retention_days,
85
- definition.name,
86
- settings,
99
+ try:
100
+ prune(
101
+ folder,
102
+ definition.retention_days,
103
+ definition.name,
104
+ settings,
105
+ )
106
+ except BackupError as exc:
107
+ logger.error(
108
+ "Retention pruning failed for group '%s' source '%s': %s",
109
+ definition.name,
110
+ folder,
111
+ exc,
112
+ )
113
+ failures.append(exc)
114
+ pruning_succeeded = False
115
+
116
+ if pruning_succeeded:
117
+ logger.info(
118
+ "Retention pruning for group '%s' completed.", definition.name
87
119
  )
88
- logger.info("Retention pruning for group '%s' completed.", definition.name)
120
+
121
+ if failures:
122
+ message = "; ".join(str(failure) for failure in failures)
123
+ if any(isinstance(failure, BorgWarningError) for failure in failures):
124
+ raise BorgWarningError(message)
125
+ raise BackupError(message)
89
126
 
90
127
 
91
128
  def main(environ: Mapping[str, str] | None = None) -> int:
92
129
  configure_logging()
130
+
131
+ def interrupt(signum: int, frame: FrameType | None) -> None:
132
+ # Repeated signals must not interrupt Borg cleanup or lock release.
133
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
134
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
135
+ raise BackupInterrupted(signum)
136
+
137
+ previous_sigterm = signal.signal(signal.SIGTERM, interrupt)
138
+ previous_sigint = signal.signal(signal.SIGINT, interrupt)
93
139
  try:
94
140
  settings = Environment.from_env(environ)
95
141
  logger.info("Starting backup using configuration %s", settings.config_path)
@@ -100,8 +146,8 @@ def main(environ: Mapping[str, str] | None = None) -> int:
100
146
 
101
147
  except BorgWarningError as exc:
102
148
  logger.error(
103
- "Backup stopped because Borg reported warnings; "
104
- "pruning was not completed: %s",
149
+ "Backup failed because Borg reported warnings; "
150
+ "retention pruning may have completed for other groups: %s",
105
151
  exc,
106
152
  )
107
153
  return 1
@@ -114,6 +160,15 @@ def main(environ: Mapping[str, str] | None = None) -> int:
114
160
  exc.strerror or "I/O error",
115
161
  )
116
162
  return 1
163
+ except BackupInterrupted as exc:
164
+ logger.error(
165
+ "Interrupted by %s; no further backups or pruning will run.",
166
+ signal.Signals(exc.signum).name,
167
+ )
168
+ return 128 + exc.signum
117
169
  except KeyboardInterrupt:
118
170
  logger.error("Interrupted.")
119
171
  return 130
172
+ finally:
173
+ signal.signal(signal.SIGTERM, previous_sigterm)
174
+ signal.signal(signal.SIGINT, previous_sigint)
@@ -3,15 +3,63 @@ import logging
3
3
  import os
4
4
  import selectors
5
5
  import shutil
6
+ import signal
6
7
  import subprocess
7
8
  import sys
8
9
  import time
10
+ from collections.abc import Generator
11
+ from contextlib import contextmanager
9
12
 
10
13
  from ..environment import Environment
11
14
  from ..errors import BorgWarningError, fail
12
15
 
13
16
  logger = logging.getLogger(__name__)
14
17
  PROGRESS_INTERVAL_SECONDS = 30
18
+ SHUTDOWN_TIMEOUT_SECONDS = 20
19
+
20
+
21
+ @contextmanager
22
+ def borg_process(
23
+ command: list[str],
24
+ environment: dict[str, str],
25
+ *,
26
+ capture_stderr: bool = False,
27
+ ) -> Generator[subprocess.Popen[bytes], None, None]:
28
+ with subprocess.Popen(
29
+ command,
30
+ env=environment,
31
+ start_new_session=True,
32
+ stderr=subprocess.PIPE if capture_stderr else None,
33
+ ) as process:
34
+ try:
35
+ yield process
36
+ except BaseException:
37
+ if process.poll() is None:
38
+ logger.warning(
39
+ "Stopping Borg (pid=%d); waiting up to %d seconds for cleanup.",
40
+ process.pid,
41
+ SHUTDOWN_TIMEOUT_SECONDS,
42
+ )
43
+ process.terminate()
44
+ try:
45
+ _, stderr = process.communicate(timeout=SHUTDOWN_TIMEOUT_SECONDS)
46
+ except subprocess.TimeoutExpired:
47
+ logger.error(
48
+ "Borg did not stop in time; killing its process group. "
49
+ "Stale Borg locks may require inspection before retrying."
50
+ )
51
+ os.killpg(process.pid, signal.SIGKILL)
52
+ _, stderr = process.communicate()
53
+ if stderr:
54
+ sys.stderr.write(stderr.decode("utf-8", errors="replace"))
55
+ sys.stderr.flush()
56
+ logger.warning("Borg stopped (exit code %d).", process.returncode)
57
+ raise
58
+
59
+
60
+ def run_without_progress(command: list[str], environment: dict[str, str]) -> int:
61
+ with borg_process(command, environment) as process:
62
+ return process.wait()
15
63
 
16
64
 
17
65
  def run_with_progress(
@@ -51,49 +99,34 @@ def run_with_progress(
51
99
  else:
52
100
  print(text, file=sys.stderr, flush=True)
53
101
 
54
- with subprocess.Popen(
55
- command,
56
- env=environment,
57
- stderr=subprocess.PIPE,
58
- ) as process:
102
+ with borg_process(command, environment, capture_stderr=True) as process:
59
103
  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
104
+ with selectors.DefaultSelector() as selector:
105
+ selector.register(process.stderr, selectors.EVENT_READ)
106
+ pending = b""
107
+ while selector.get_map():
108
+ for key, _ in selector.select(max(0, next_report - time.monotonic())):
109
+ chunk = os.read(key.fd, 65536)
110
+ if not chunk:
111
+ selector.unregister(key.fileobj)
112
+ if pending:
113
+ handle_line(pending)
114
+ break
115
+ pending += chunk
116
+ while b"\n" in pending:
117
+ line, pending = pending.split(b"\n", 1)
118
+ handle_line(line)
119
+ now = time.monotonic()
120
+ if now >= next_report and selector.get_map():
121
+ logger.info(
122
+ "Borg progress for %s (elapsed_seconds=%.0f; "
123
+ "latest reported: %s).",
124
+ context,
125
+ now - started_at,
126
+ latest_progress,
127
+ )
128
+ next_report = now + PROGRESS_INTERVAL_SECONDS
129
+ return process.wait()
97
130
 
98
131
 
99
132
  def ensure_borg_binary() -> None:
@@ -141,11 +174,7 @@ def run_borg(
141
174
  context,
142
175
  )
143
176
  else:
144
- returncode = subprocess.run(
145
- command,
146
- env=borg_environment(settings),
147
- check=False,
148
- ).returncode
177
+ returncode = run_without_progress(command, borg_environment(settings))
149
178
  except FileNotFoundError:
150
179
  fail("Required executable 'borg' could not be started.")
151
180
  except OSError as exc:
@@ -9,5 +9,11 @@ class BorgWarningError(BackupError):
9
9
  """Raised when Borg completes with warnings that make the run unsafe."""
10
10
 
11
11
 
12
+ class BackupInterrupted(KeyboardInterrupt):
13
+ def __init__(self, signum: int) -> None:
14
+ self.signum = signum
15
+ super().__init__(f"Interrupted by signal {signum}.")
16
+
17
+
12
18
  def fail(message: str) -> NoReturn:
13
19
  raise BackupError(message)
@@ -1,3 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: declarative-data-backup
3
+ Version: 0.4.1
4
+ Summary: Declarative Borg-based data backups
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: PyYAML<7,>=6
8
+
1
9
  # Declarative Data Backup
2
10
 
3
11
  Declarative Data Backup creates encrypted Borg archives for configured source
@@ -165,6 +173,16 @@ counters or Borg phase. Unchanged counters do not prove forward progress;
165
173
  before Borg reports a phase, the status only indicates that the process is
166
174
  running. Warnings and statistics are not throttled.
167
175
 
176
+ On SIGTERM (including normal Kubernetes pod termination) or Ctrl+C, the
177
+ wrapper asks Borg to stop and waits up to 20 seconds for cleanup before
178
+ releasing its coordination lock. No further sources or pruning are started.
179
+ The exit status is 143 for SIGTERM or 130 for Ctrl+C. Repeated termination
180
+ signals are ignored during cleanup. If Borg does not stop within the timeout,
181
+ its process group is killed and an error warns that Borg locks may remain.
182
+ Use a pod termination grace period of at least 30 seconds (60 recommended);
183
+ forced deletion or SIGKILL cannot be handled gracefully. Never break Borg
184
+ locks without first confirming that no repository operation is still running.
185
+
168
186
  ## Configuration reference
169
187
 
170
188
  The top level must be a nonempty YAML mapping of backup group names to group
@@ -228,10 +246,12 @@ The stable source series is derived from the canonical source path and includes
228
246
  a SHA-256 suffix. Borg receives the canonical absolute path as the source, so
229
247
  the original directory hierarchy is retained inside the archive.
230
248
 
231
- The application creates all configured archives before pruning any source.
232
- If a create fails or Borg reports warnings, the run stops and no pruning is
233
- performed. Retries use a new run UUID and may create another archive for that
234
- day; archives are never overwritten to handle a collision.
249
+ The application attempts every configured backup group before pruning any
250
+ source. If a group has a create failure or Borg reports warnings, pruning is
251
+ skipped for that group, but other groups that completed successfully are still
252
+ pruned. Failures are reported and make the overall run fail. Retries use a new
253
+ run UUID and may create another archive for that day; archives are never
254
+ overwritten to handle a collision.
235
255
 
236
256
  `--keep-daily N` retains the latest archive in up to `N` daily buckets. It does
237
257
  not guarantee `N` successful calendar days of recovery if scheduled runs fail
@@ -79,7 +79,7 @@ def test_main_runs_all_creates_before_any_prune(
79
79
  assert "Retention pruning for group 'media_backup' completed." in caplog.text
80
80
 
81
81
 
82
- def test_main_warning_aborts_remaining_creates_and_all_pruning(
82
+ def test_main_warning_skips_failed_group_but_prunes_successful_groups(
83
83
  settings_factory,
84
84
  monkeypatch,
85
85
  caplog,
@@ -91,14 +91,15 @@ def test_main_warning_aborts_remaining_creates_and_all_pruning(
91
91
  second.mkdir()
92
92
  _write_config(
93
93
  settings,
94
- f"media:\n folders: [{first}, {second}]\n",
94
+ f"failed:\n folders: [{first}]\nsucceeded:\n folders: [{second}]\n",
95
95
  )
96
96
  _configure_preflight(monkeypatch)
97
97
  calls = []
98
98
 
99
99
  def warning(folder, *args):
100
100
  calls.append(("create", folder))
101
- raise BorgWarningError(f"warning for {folder}")
101
+ if folder == first:
102
+ raise BorgWarningError(f"warning for {folder}")
102
103
 
103
104
  monkeypatch.setattr(backup, "backup_folder", warning)
104
105
  monkeypatch.setattr(
@@ -109,7 +110,11 @@ def test_main_warning_aborts_remaining_creates_and_all_pruning(
109
110
  caplog.set_level(logging.ERROR)
110
111
 
111
112
  assert backup.main(_settings_environment(settings)) == 1
112
- assert calls == [("create", first)]
113
+ assert calls == [
114
+ ("create", first),
115
+ ("create", second),
116
+ ("prune", second),
117
+ ]
113
118
  assert "warnings" in caplog.text
114
119
  assert str(first) in caplog.text
115
120
 
@@ -269,6 +274,7 @@ def test_main_prune_failure_is_nonzero_and_scoped(
269
274
  ("create", first),
270
275
  ("create", second),
271
276
  ("prune", first),
277
+ ("prune", second),
272
278
  ]
273
279
 
274
280