declarative-data-backup 0.1.0__tar.gz → 0.2.0__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.1.0 → declarative_data_backup-0.2.0}/PKG-INFO +31 -6
  2. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/README.md +30 -5
  3. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/pyproject.toml +1 -1
  4. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/backup.py +8 -7
  5. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/borg/borg.py +12 -9
  6. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/config/backup_config/models.py +6 -0
  7. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/config/backup_config/utils.py +50 -9
  8. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/config/backup_config/yaml_parser.py +23 -3
  9. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup.egg-info/PKG-INFO +31 -6
  10. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/tests/test_backup.py +38 -3
  11. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/setup.cfg +0 -0
  12. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/__init__.py +0 -0
  13. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/__main__.py +0 -0
  14. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/borg/__init__.py +0 -0
  15. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/borg/archive_naming.py +0 -0
  16. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/borg/borg_cli.py +0 -0
  17. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/config/__init__.py +0 -0
  18. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/config/backup_config/__init__.py +0 -0
  19. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/config/backup_config/yaml_raw_loader.py +0 -0
  20. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/config/logging.py +0 -0
  21. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/environment.py +0 -0
  22. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup/errors.py +0 -0
  23. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup.egg-info/SOURCES.txt +0 -0
  24. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup.egg-info/dependency_links.txt +0 -0
  25. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/src/declarative_data_backup.egg-info/requires.txt +0 -0
  26. {declarative_data_backup-0.1.0 → declarative_data_backup-0.2.0}/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.1.0
3
+ Version: 0.2.0
4
4
  Summary: Declarative Borg-based data backups
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -120,6 +120,18 @@ postgres_backup:
120
120
  - /server_root/path/to/postgres-backups
121
121
  required_mounts:
122
122
  - /server_root/path/to/postgres-backups
123
+
124
+ apprise_backup:
125
+ files:
126
+ - /server_root/data/apprise/apprise.yml
127
+
128
+ jellyfin_backup:
129
+ folders:
130
+ - /server_root/data/jellyfin
131
+ exclude:
132
+ - "**/cache/**"
133
+ - "**/log/**"
134
+ - "**/transcodes/**"
123
135
  ```
124
136
 
125
137
  Set the required environment variables before starting the process:
@@ -159,14 +171,22 @@ the run fails and pruning is skipped.
159
171
  The top level must be a nonempty YAML mapping of backup group names to group
160
172
  mappings. Group names may contain ASCII letters, digits, `_`, and `-`, and
161
173
  must be at most 64 characters. Duplicate YAML keys, unknown group fields,
162
- invalid values, and malformed or empty configuration are rejected before any
163
- create or prune operation.
174
+ invalid types, unsupported pattern selectors, and empty configuration are
175
+ rejected before any create or prune operation.
164
176
 
165
177
  Each group supports:
166
178
 
167
- - `folders` (required): a nonempty list of absolute directory paths. Each
168
- source must exist, be readable, and resolve inside `SERVER_ROOT`. Duplicate
169
- canonical paths and parent/child overlaps across groups are rejected.
179
+ - `folders` (optional): list of absolute directory paths.
180
+ - `files` (optional): list of absolute regular-file paths. Folders and files
181
+ share the same containment, readability, duplicate, and overlap checks;
182
+ configure at least one source per group.
183
+ - `exclude` (optional): list of Borg 1.4 glob patterns, scoped to this group
184
+ and passed directly to Borg for folder sources. To avoid approximating
185
+ Borg's pattern matching, a group with explicit `files` cannot also configure
186
+ `exclude`; this is rejected before any Borg operation.
187
+ Patterns are otherwise passed unchanged to Borg, which evaluates their
188
+ matching semantics. If Borg rejects a pattern during create, the run stops
189
+ before pruning any source.
170
190
  - `compression` (optional): defaults to `auto,zstd,22`. Supported Borg 1.4
171
191
  codecs include `none`, `lz4`, `zstd`, `zlib`, and `lzma`; `auto` may precede
172
192
  a codec. Codec levels are checked against supported ranges: zstd `1`-`22`,
@@ -187,6 +207,11 @@ does not prove that nested host filesystems are mounted. In particular,
187
207
  `allow_empty: true` does not prove that a disk is present: an empty source may
188
208
  be legitimate and can be archived.
189
209
 
210
+ Backing up a live SQLite database with `files` or `folders` does not produce a
211
+ consistent database snapshot. Use an application-supported export, backup API,
212
+ or coordinated filesystem snapshot for live database data. Exclusions do not
213
+ make a database backup consistent.
214
+
190
215
  Paths are resolved before backup. A top-level source symlink is allowed only
191
216
  when its canonical target remains inside `SERVER_ROOT`; symlinks within a
192
217
  source are stored as links rather than followed.
@@ -107,6 +107,18 @@ postgres_backup:
107
107
  - /server_root/path/to/postgres-backups
108
108
  required_mounts:
109
109
  - /server_root/path/to/postgres-backups
110
+
111
+ apprise_backup:
112
+ files:
113
+ - /server_root/data/apprise/apprise.yml
114
+
115
+ jellyfin_backup:
116
+ folders:
117
+ - /server_root/data/jellyfin
118
+ exclude:
119
+ - "**/cache/**"
120
+ - "**/log/**"
121
+ - "**/transcodes/**"
110
122
  ```
111
123
 
112
124
  Set the required environment variables before starting the process:
@@ -146,14 +158,22 @@ the run fails and pruning is skipped.
146
158
  The top level must be a nonempty YAML mapping of backup group names to group
147
159
  mappings. Group names may contain ASCII letters, digits, `_`, and `-`, and
148
160
  must be at most 64 characters. Duplicate YAML keys, unknown group fields,
149
- invalid values, and malformed or empty configuration are rejected before any
150
- create or prune operation.
161
+ invalid types, unsupported pattern selectors, and empty configuration are
162
+ rejected before any create or prune operation.
151
163
 
152
164
  Each group supports:
153
165
 
154
- - `folders` (required): a nonempty list of absolute directory paths. Each
155
- source must exist, be readable, and resolve inside `SERVER_ROOT`. Duplicate
156
- canonical paths and parent/child overlaps across groups are rejected.
166
+ - `folders` (optional): list of absolute directory paths.
167
+ - `files` (optional): list of absolute regular-file paths. Folders and files
168
+ share the same containment, readability, duplicate, and overlap checks;
169
+ configure at least one source per group.
170
+ - `exclude` (optional): list of Borg 1.4 glob patterns, scoped to this group
171
+ and passed directly to Borg for folder sources. To avoid approximating
172
+ Borg's pattern matching, a group with explicit `files` cannot also configure
173
+ `exclude`; this is rejected before any Borg operation.
174
+ Patterns are otherwise passed unchanged to Borg, which evaluates their
175
+ matching semantics. If Borg rejects a pattern during create, the run stops
176
+ before pruning any source.
157
177
  - `compression` (optional): defaults to `auto,zstd,22`. Supported Borg 1.4
158
178
  codecs include `none`, `lz4`, `zstd`, `zlib`, and `lzma`; `auto` may precede
159
179
  a codec. Codec levels are checked against supported ranges: zstd `1`-`22`,
@@ -174,6 +194,11 @@ does not prove that nested host filesystems are mounted. In particular,
174
194
  `allow_empty: true` does not prove that a disk is present: an empty source may
175
195
  be legitimate and can be archived.
176
196
 
197
+ Backing up a live SQLite database with `files` or `folders` does not produce a
198
+ consistent database snapshot. Use an application-supported export, backup API,
199
+ or coordinated filesystem snapshot for live database data. Exclusions do not
200
+ make a database backup consistent.
201
+
177
202
  Paths are resolved before backup. A top-level source symlink is allowed only
178
203
  when its canonical target remains inside `SERVER_ROOT`; symlinks within a
179
204
  source are stored as links rather than followed.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "declarative-data-backup"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Declarative Borg-based data backups"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -44,11 +44,11 @@ def run_backup(
44
44
  settings: Environment,
45
45
  definitions: list[BackupDefinition],
46
46
  ) -> None:
47
- total_folders = sum(len(definition.folders) for definition in definitions)
47
+ total_sources = sum(len(definition.sources) for definition in definitions)
48
48
  logger.info(
49
- "Validated %d backup groups containing %d folders",
49
+ "Validated %d backup groups containing %d sources",
50
50
  len(definitions),
51
- total_folders,
51
+ total_sources,
52
52
  )
53
53
 
54
54
  run_id = uuid.uuid4().hex
@@ -56,11 +56,11 @@ def run_backup(
56
56
  created_at = datetime.now(UTC)
57
57
  for definition in definitions:
58
58
  logger.info(
59
- "Starting backup group '%s' (%d folders).",
59
+ "Starting backup group '%s' (%d sources).",
60
60
  definition.name,
61
- len(definition.folders),
61
+ len(definition.sources),
62
62
  )
63
- for folder in definition.folders:
63
+ for folder in definition.sources:
64
64
  backup_folder(
65
65
  folder,
66
66
  definition.compression,
@@ -68,10 +68,11 @@ def run_backup(
68
68
  settings,
69
69
  created_at,
70
70
  run_id,
71
+ definition.excludes,
71
72
  )
72
73
 
73
74
  for definition in definitions:
74
- for folder in definition.folders:
75
+ for folder in definition.sources:
75
76
  prune(
76
77
  folder,
77
78
  definition.retention_days,
@@ -29,6 +29,7 @@ def backup_folder(
29
29
  settings: Environment,
30
30
  created_at: datetime,
31
31
  run_id: str,
32
+ excludes: tuple[str, ...] = (),
32
33
  ) -> None:
33
34
  name = archive_name_escaper(
34
35
  folder,
@@ -38,17 +39,19 @@ def backup_folder(
38
39
  context = f"group={group!r} source={folder}"
39
40
  started_at = time.monotonic()
40
41
  logger.info("Starting backup for %s as archive %s.", context, name)
42
+ args = [
43
+ "create",
44
+ "--verbose",
45
+ "--stats",
46
+ "--compression",
47
+ compression,
48
+ ]
49
+ for pattern in excludes:
50
+ args.extend(("--exclude", pattern))
51
+ args.extend((f"{settings.borg_repo}::{name}", str(folder)))
41
52
  try:
42
53
  run_borg(
43
- [
44
- "create",
45
- "--verbose",
46
- "--stats",
47
- "--compression",
48
- compression,
49
- f"{settings.borg_repo}::{name}",
50
- str(folder),
51
- ],
54
+ args,
52
55
  settings,
53
56
  "create",
54
57
  context,
@@ -10,3 +10,9 @@ class BackupDefinition:
10
10
  retention_days: int
11
11
  required_mounts: tuple[Path, ...] = ()
12
12
  allow_empty: bool = True
13
+ files: tuple[Path, ...] = ()
14
+ excludes: tuple[str, ...] = ()
15
+
16
+ @property
17
+ def sources(self) -> tuple[Path, ...]:
18
+ return self.folders + self.files
@@ -37,8 +37,8 @@ def parse_folders(
37
37
  group: str,
38
38
  allow_empty: bool,
39
39
  ) -> tuple[Path, ...]:
40
- if not isinstance(raw_folders, list) or not raw_folders:
41
- fail(f"'{group}.folders' must be a nonempty YAML list.")
40
+ if not isinstance(raw_folders, list):
41
+ fail(f"'{group}.folders' must be a YAML list.")
42
42
 
43
43
  folders = []
44
44
  for raw_folder in raw_folders:
@@ -60,6 +60,47 @@ def parse_folders(
60
60
  return tuple(folders)
61
61
 
62
62
 
63
+ def parse_files(
64
+ raw_files: Any,
65
+ server_root: Path,
66
+ group: str,
67
+ ) -> tuple[Path, ...]:
68
+ if not isinstance(raw_files, list):
69
+ fail(f"'{group}.files' must be a YAML list.")
70
+
71
+ files = []
72
+ for raw_file in raw_files:
73
+ file = resolve_path(raw_file, server_root, f"'{group}.files' entry")
74
+ if not file.is_file():
75
+ fail(f"Backup source is not a regular file: {file}.")
76
+ if not os.access(file, os.R_OK):
77
+ fail(f"Backup source is not readable: {file}.")
78
+ files.append(file)
79
+ return tuple(files)
80
+
81
+
82
+ def parse_excludes(raw_excludes: Any, group: str) -> tuple[str, ...]:
83
+ if not isinstance(raw_excludes, list):
84
+ fail(f"'{group}.exclude' must be a YAML list.")
85
+
86
+ excludes = []
87
+ for pattern in raw_excludes:
88
+ if not isinstance(pattern, str) or not pattern.strip() or "\0" in pattern:
89
+ fail(f"'{group}.exclude' entries must be nonempty Borg glob patterns.")
90
+
91
+ pattern_type, separator, pattern_body = pattern.partition(":")
92
+ if separator and len(pattern_type) == 2:
93
+ if pattern_type not in {"fm", "sh", "pf", "pp"}:
94
+ fail(
95
+ f"'{group}.exclude' entries must use Borg glob patterns "
96
+ "(fm:, sh:, pf:, or pp:)."
97
+ )
98
+ if not pattern_body:
99
+ fail(f"'{group}.exclude' entries must be nonempty Borg patterns.")
100
+ excludes.append(pattern)
101
+ return tuple(excludes)
102
+
103
+
63
104
  def parse_required_mounts(
64
105
  raw_mounts: Any,
65
106
  server_root: Path,
@@ -80,23 +121,23 @@ def parse_required_mounts(
80
121
  return mounts
81
122
 
82
123
 
83
- def check_folders_for_overlappings(definitions: list[BackupDefinition]) -> None:
124
+ def check_sources_for_overlappings(definitions: list[BackupDefinition]) -> None:
84
125
  owners: dict[Path, str] = {}
85
126
  for definition in definitions:
86
- for folder in definition.folders:
127
+ for source in definition.sources:
87
128
  for existing, owner in owners.items():
88
- if folder == existing:
129
+ if source == existing:
89
130
  fail(
90
- f"Duplicate canonical backup source {folder} "
131
+ f"Duplicate canonical backup source {source} "
91
132
  f"(already configured by '{owner}')."
92
133
  )
93
- if folder in existing.parents or existing in folder.parents:
134
+ if source in existing.parents or existing in source.parents:
94
135
  fail(
95
136
  f"Overlapping backup sources are not allowed: "
96
137
  f"'{owner}' uses {existing}, while "
97
- f"'{definition.name}' uses {folder}."
138
+ f"'{definition.name}' uses {source}."
98
139
  )
99
- owners[folder] = definition.name
140
+ owners[source] = definition.name
100
141
 
101
142
 
102
143
  def _mountinfo_path(raw_mount: str) -> Path:
@@ -6,8 +6,10 @@ from ...environment import DEFAULT_COMPRESSION, DEFAULT_RETENTION_DAYS
6
6
  from ...errors import fail
7
7
  from .models import BackupDefinition
8
8
  from .utils import (
9
- check_folders_for_overlappings,
10
9
  check_required_mounts_are_mounted,
10
+ check_sources_for_overlappings,
11
+ parse_excludes,
12
+ parse_files,
11
13
  parse_folders,
12
14
  parse_required_mounts,
13
15
  )
@@ -15,12 +17,17 @@ from .utils import (
15
17
  _GROUP_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z")
16
18
  _GROUP_KEYS = {
17
19
  "folders",
20
+ "files",
21
+ "exclude",
18
22
  "compression",
19
23
  "retention_days",
20
24
  "required_mounts",
21
25
  "allow_empty",
22
26
  }
23
27
  _GROUP_DEFAULTS = {
28
+ "folders": [],
29
+ "files": [],
30
+ "exclude": [],
24
31
  "compression": DEFAULT_COMPRESSION,
25
32
  "retention_days": DEFAULT_RETENTION_DAYS,
26
33
  "required_mounts": [],
@@ -102,9 +109,20 @@ def _parse_definition(name: Any, raw: Any, server_root: Path) -> BackupDefinitio
102
109
  if not isinstance(allow_empty, bool):
103
110
  fail(f"'{name}.allow_empty' must be a boolean.")
104
111
 
112
+ folders = parse_folders(group["folders"], server_root, name, allow_empty)
113
+ files = parse_files(group["files"], server_root, name)
114
+ excludes = parse_excludes(group["exclude"], name)
115
+ if not folders and not files:
116
+ fail(f"'{name}' must configure at least one folder or file source.")
117
+ if files and excludes:
118
+ fail(
119
+ f"'{name}' cannot combine 'files' and 'exclude': Borg exclusion "
120
+ "of an explicitly selected file cannot be verified safely."
121
+ )
122
+
105
123
  return BackupDefinition(
106
124
  name=name,
107
- folders=parse_folders(group.get("folders"), server_root, name, allow_empty),
125
+ folders=folders,
108
126
  compression=compression,
109
127
  retention_days=retention_days,
110
128
  required_mounts=parse_required_mounts(
@@ -113,6 +131,8 @@ def _parse_definition(name: Any, raw: Any, server_root: Path) -> BackupDefinitio
113
131
  name,
114
132
  ),
115
133
  allow_empty=allow_empty,
134
+ files=files,
135
+ excludes=excludes,
116
136
  )
117
137
 
118
138
 
@@ -131,6 +151,6 @@ def parse_backup_definitions(
131
151
  definitions = [
132
152
  _parse_definition(name, raw, server_root) for name, raw in data.items()
133
153
  ]
134
- check_folders_for_overlappings(definitions)
154
+ check_sources_for_overlappings(definitions)
135
155
  check_required_mounts_are_mounted(definitions, mountinfo_path)
136
156
  return definitions
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: declarative-data-backup
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Declarative Borg-based data backups
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -120,6 +120,18 @@ postgres_backup:
120
120
  - /server_root/path/to/postgres-backups
121
121
  required_mounts:
122
122
  - /server_root/path/to/postgres-backups
123
+
124
+ apprise_backup:
125
+ files:
126
+ - /server_root/data/apprise/apprise.yml
127
+
128
+ jellyfin_backup:
129
+ folders:
130
+ - /server_root/data/jellyfin
131
+ exclude:
132
+ - "**/cache/**"
133
+ - "**/log/**"
134
+ - "**/transcodes/**"
123
135
  ```
124
136
 
125
137
  Set the required environment variables before starting the process:
@@ -159,14 +171,22 @@ the run fails and pruning is skipped.
159
171
  The top level must be a nonempty YAML mapping of backup group names to group
160
172
  mappings. Group names may contain ASCII letters, digits, `_`, and `-`, and
161
173
  must be at most 64 characters. Duplicate YAML keys, unknown group fields,
162
- invalid values, and malformed or empty configuration are rejected before any
163
- create or prune operation.
174
+ invalid types, unsupported pattern selectors, and empty configuration are
175
+ rejected before any create or prune operation.
164
176
 
165
177
  Each group supports:
166
178
 
167
- - `folders` (required): a nonempty list of absolute directory paths. Each
168
- source must exist, be readable, and resolve inside `SERVER_ROOT`. Duplicate
169
- canonical paths and parent/child overlaps across groups are rejected.
179
+ - `folders` (optional): list of absolute directory paths.
180
+ - `files` (optional): list of absolute regular-file paths. Folders and files
181
+ share the same containment, readability, duplicate, and overlap checks;
182
+ configure at least one source per group.
183
+ - `exclude` (optional): list of Borg 1.4 glob patterns, scoped to this group
184
+ and passed directly to Borg for folder sources. To avoid approximating
185
+ Borg's pattern matching, a group with explicit `files` cannot also configure
186
+ `exclude`; this is rejected before any Borg operation.
187
+ Patterns are otherwise passed unchanged to Borg, which evaluates their
188
+ matching semantics. If Borg rejects a pattern during create, the run stops
189
+ before pruning any source.
170
190
  - `compression` (optional): defaults to `auto,zstd,22`. Supported Borg 1.4
171
191
  codecs include `none`, `lz4`, `zstd`, `zlib`, and `lzma`; `auto` may precede
172
192
  a codec. Codec levels are checked against supported ranges: zstd `1`-`22`,
@@ -187,6 +207,11 @@ does not prove that nested host filesystems are mounted. In particular,
187
207
  `allow_empty: true` does not prove that a disk is present: an empty source may
188
208
  be legitimate and can be archived.
189
209
 
210
+ Backing up a live SQLite database with `files` or `folders` does not produce a
211
+ consistent database snapshot. Use an application-supported export, backup API,
212
+ or coordinated filesystem snapshot for live database data. Exclusions do not
213
+ make a database backup consistent.
214
+
190
215
  Paths are resolved before backup. A top-level source symlink is allowed only
191
216
  when its canonical target remains inside `SERVER_ROOT`; symlinks within a
192
217
  source are stored as links rather than followed.
@@ -48,8 +48,8 @@ def test_main_runs_all_creates_before_any_prune(
48
48
  monkeypatch.setattr(
49
49
  backup,
50
50
  "backup_folder",
51
- lambda folder, compression, group, settings, created_at, run_id: events.append(
52
- ("create", group, folder, compression, run_id)
51
+ lambda folder, compression, group, settings, created_at, run_id, excludes: (
52
+ events.append(("create", group, folder, compression, run_id, excludes))
53
53
  ),
54
54
  )
55
55
  monkeypatch.setattr(
@@ -153,7 +153,7 @@ def test_retry_after_partial_create_uses_a_new_run_id_before_pruning(
153
153
  prunes = []
154
154
  fail_second = True
155
155
 
156
- def create(folder, compression, group, settings, created_at, run_id):
156
+ def create(folder, compression, group, settings, created_at, run_id, excludes):
157
157
  nonlocal fail_second
158
158
  creates.append((folder, run_id))
159
159
  if fail_second and folder == second:
@@ -268,6 +268,41 @@ def test_main_prune_failure_is_nonzero_and_scoped(
268
268
  ]
269
269
 
270
270
 
271
+ def test_exclusions_are_passed_only_to_their_backup_group(
272
+ settings_factory,
273
+ monkeypatch,
274
+ ):
275
+ settings = settings_factory()
276
+ first = settings.server_root / "first"
277
+ first.mkdir()
278
+ second = settings.server_root / "second.yml"
279
+ second.write_text("config\n", encoding="utf-8")
280
+ _write_config(
281
+ settings,
282
+ "first:\n"
283
+ f" folders: [{first}]\n"
284
+ " exclude: ['**/cache/**']\n"
285
+ "second:\n"
286
+ f" files: [{second}]\n",
287
+ )
288
+ _configure_preflight(monkeypatch)
289
+ calls = []
290
+ monkeypatch.setattr(
291
+ backup,
292
+ "backup_folder",
293
+ lambda source, compression, group, settings, created_at, run_id, excludes: (
294
+ calls.append((group, source, excludes))
295
+ ),
296
+ )
297
+ monkeypatch.setattr(backup, "prune", lambda *args: None)
298
+
299
+ assert backup.main(_settings_environment(settings)) == 0
300
+ assert calls == [
301
+ ("first", first, ("**/cache/**",)),
302
+ ("second", second, ()),
303
+ ]
304
+
305
+
271
306
  def test_main_reports_unexpected_io_errors_cleanly(
272
307
  settings_factory,
273
308
  monkeypatch,