hlsync 1.17.2__py3-none-any.whl

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.

Potentially problematic release.


This version of hlsync might be problematic. Click here for more details.

hlsync/config.py ADDED
@@ -0,0 +1,576 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path, PurePosixPath
8
+ from typing import Any
9
+
10
+ from hlsync.gitignore import GitIgnores
11
+ from hlsync.rules import RuleAction, RuleError, RuleSet, RuleTarget, SyncRule
12
+ from hlsync.storage import write_json_atomic
13
+
14
+ CONFIG_VERSION = 9
15
+ GLOBAL_RULES_VERSION = 1
16
+ PROFILE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
17
+ ENVIRONMENT_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
18
+ DEFAULT_USERNAME_ENV = "PROD_FTPS_USERNAME"
19
+ DEFAULT_PASSWORD_ENV = "PROD_FTPS_PASSWORD"
20
+ DEFAULT_GLOBAL_RULES = (
21
+ SyncRule(1, "exclude", "**/.git/**"),
22
+ SyncRule(2, "exclude", "**/.svn/**"),
23
+ SyncRule(3, "exclude", "**/.hg/**"),
24
+ SyncRule(4, "exclude", "**/.DS_Store"),
25
+ SyncRule(5, "exclude", "**/Thumbs.db"),
26
+ SyncRule(6, "exclude", "**/desktop.ini"),
27
+ SyncRule(7, "exclude", "**/.gitignore"),
28
+ SyncRule(8, "exclude", "**/.gitmodules"),
29
+ SyncRule(9, "exclude", "**/error_log"),
30
+ )
31
+
32
+
33
+ class ConfigurationError(ValueError):
34
+ """Raised when configuration is invalid or cannot be decoded."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class RuleUpdate:
39
+ added: tuple[SyncRule, ...] = ()
40
+ removed: tuple[SyncRule, ...] = ()
41
+
42
+
43
+ def validate_profile_name(name: str) -> str:
44
+ if not PROFILE_NAME_PATTERN.fullmatch(name):
45
+ raise ConfigurationError(
46
+ "profile name must start with a letter or digit and contain "
47
+ "only letters, digits, '.', '_', or '-'"
48
+ )
49
+ return name
50
+
51
+
52
+ def _required_string(value: Any, field_name: str) -> str:
53
+ if not isinstance(value, str) or not value.strip():
54
+ raise ConfigurationError(f"{field_name} must be a non-empty string")
55
+ return value.strip()
56
+
57
+
58
+ def _environment_name(value: Any, field_name: str) -> str:
59
+ name = _required_string(value, field_name)
60
+ if not ENVIRONMENT_NAME_PATTERN.fullmatch(name):
61
+ raise ConfigurationError(
62
+ f"{field_name} is not a valid environment-variable name"
63
+ )
64
+ return name
65
+
66
+
67
+ def _normalize_remote_root(value: Any) -> str:
68
+ remote_value = _required_string(value, "profile remote root")
69
+ remote_path = PurePosixPath(remote_value)
70
+ if not remote_path.is_absolute():
71
+ raise ConfigurationError("profile remote root must be absolute")
72
+ if ".." in remote_path.parts:
73
+ raise ConfigurationError("profile remote root cannot contain '..'")
74
+ components = [part for part in remote_path.parts if part not in {"/", "//"}]
75
+ return "/" + "/".join(components)
76
+
77
+
78
+ def canonical_local_root(directory: str | Path) -> str:
79
+ candidate = Path(directory).expanduser()
80
+ try:
81
+ resolved = candidate.resolve(strict=True)
82
+ except OSError as error:
83
+ raise ConfigurationError(f"local root does not exist: {candidate}") from error
84
+ if not resolved.is_dir():
85
+ raise ConfigurationError(f"local root is not a directory: {candidate}")
86
+ if resolved.parent == resolved:
87
+ raise ConfigurationError("local root cannot be the filesystem root")
88
+ return os.fspath(resolved)
89
+
90
+
91
+ def _validate_stored_local_root(value: Any) -> str | None:
92
+ if value is None:
93
+ return None
94
+ local = _required_string(value, "profile local root")
95
+ path = Path(local)
96
+ if not path.is_absolute():
97
+ raise ConfigurationError("profile local root must be absolute")
98
+ if path.parent == path:
99
+ raise ConfigurationError("profile local root cannot be the filesystem root")
100
+ if os.path.normpath(local) != local:
101
+ raise ConfigurationError("profile local root must be normalized")
102
+ return local
103
+
104
+
105
+ def _paths_overlap(first: Path, second: Path) -> bool:
106
+ return first == second or first in second.parents or second in first.parents
107
+
108
+
109
+ def _rule_action_already_applies(
110
+ rules: tuple[SyncRule, ...],
111
+ action: RuleAction,
112
+ pattern: str,
113
+ target: RuleTarget,
114
+ gitignores: GitIgnores | None = None,
115
+ ) -> bool:
116
+ if "*" in pattern:
117
+ return False
118
+ winner = next(
119
+ (
120
+ rule
121
+ for rule in reversed(rules)
122
+ if rule.target == target and rule.matches(pattern)
123
+ ),
124
+ None,
125
+ )
126
+ excluded = (
127
+ winner.action == "exclude"
128
+ if winner is not None
129
+ else bool(target == "local" and gitignores and gitignores.excludes(pattern))
130
+ )
131
+ return excluded if action == "exclude" else not excluded
132
+
133
+
134
+ def _append_rules(
135
+ rules: tuple[SyncRule, ...],
136
+ action: RuleAction,
137
+ patterns: tuple[str, ...],
138
+ *,
139
+ target: RuleTarget = "local",
140
+ base_rules: tuple[SyncRule, ...] = (),
141
+ gitignores: GitIgnores | None = None,
142
+ ) -> tuple[tuple[SyncRule, ...], RuleUpdate]:
143
+ ordered = list(rules)
144
+ added: list[SyncRule] = []
145
+ removed: list[SyncRule] = []
146
+ unique_patterns: list[str] = []
147
+ for pattern in patterns:
148
+ unique_patterns = [item for item in unique_patterns if item != pattern]
149
+ unique_patterns.append(pattern)
150
+ for pattern in unique_patterns:
151
+ replaced = tuple(
152
+ rule
153
+ for rule in ordered
154
+ if rule.pattern == pattern and rule.target == target
155
+ )
156
+ ordered = [
157
+ rule for rule in ordered if rule.pattern != pattern or rule.target != target
158
+ ]
159
+ if replaced and _rule_action_already_applies(
160
+ (*base_rules, *ordered), action, pattern, target, gitignores
161
+ ):
162
+ removed.extend(replaced)
163
+ continue
164
+ next_id = max((rule.id for rule in ordered), default=0) + 1
165
+ rule = SyncRule(next_id, action, pattern, target)
166
+ ordered.append(rule)
167
+ added.append(rule)
168
+ return (
169
+ tuple(ordered),
170
+ RuleUpdate(tuple(added), tuple(removed)),
171
+ )
172
+
173
+
174
+ def _remove_rule(
175
+ rules: tuple[SyncRule, ...],
176
+ rule_id: int,
177
+ *,
178
+ scope: str,
179
+ ) -> tuple[tuple[SyncRule, ...], SyncRule]:
180
+ removed = next((rule for rule in rules if rule.id == rule_id), None)
181
+ if removed is None:
182
+ raise ConfigurationError(f"{scope} has no rule with id {rule_id}")
183
+ return tuple(rule for rule in rules if rule.id != rule_id), removed
184
+
185
+
186
+ def _validate_rule_sequence(rules: tuple[SyncRule, ...]) -> tuple[SyncRule, ...]:
187
+ try:
188
+ rule_set = RuleSet(rules)
189
+ except RuleError as error:
190
+ raise ConfigurationError(str(error)) from error
191
+ return rule_set.rules
192
+
193
+
194
+ @dataclass(frozen=True)
195
+ class ProfileConfiguration:
196
+ host: str
197
+ remote_root: str
198
+ port: int = 21
199
+ username_env: str = DEFAULT_USERNAME_ENV
200
+ password_env: str = DEFAULT_PASSWORD_ENV
201
+ type: str = "ftps"
202
+ local_root: str | None = None
203
+ rules: tuple[SyncRule, ...] = ()
204
+
205
+ def __post_init__(self) -> None:
206
+ object.__setattr__(self, "host", _required_string(self.host, "host"))
207
+ object.__setattr__(
208
+ self,
209
+ "remote_root",
210
+ _normalize_remote_root(self.remote_root),
211
+ )
212
+ if self.type != "ftps":
213
+ raise ConfigurationError("profile protocol must be 'ftps'")
214
+ if isinstance(self.port, bool) or not isinstance(self.port, int):
215
+ raise ConfigurationError("port must be an integer")
216
+ if not 1 <= self.port <= 65535:
217
+ raise ConfigurationError("port must be between 1 and 65535")
218
+ object.__setattr__(
219
+ self,
220
+ "username_env",
221
+ _environment_name(self.username_env, "username_env"),
222
+ )
223
+ object.__setattr__(
224
+ self,
225
+ "password_env",
226
+ _environment_name(self.password_env, "password_env"),
227
+ )
228
+ object.__setattr__(
229
+ self,
230
+ "local_root",
231
+ _validate_stored_local_root(self.local_root),
232
+ )
233
+ object.__setattr__(
234
+ self,
235
+ "rules",
236
+ _validate_rule_sequence(self.rules),
237
+ )
238
+ if self.local_root is None and self.rules:
239
+ raise ConfigurationError("an unmapped profile cannot have rules")
240
+
241
+ def with_roots(
242
+ self,
243
+ *,
244
+ local_root: str,
245
+ remote_root: str,
246
+ ) -> ProfileConfiguration:
247
+ return ProfileConfiguration(
248
+ host=self.host,
249
+ remote_root=remote_root,
250
+ port=self.port,
251
+ username_env=self.username_env,
252
+ password_env=self.password_env,
253
+ type=self.type,
254
+ local_root=local_root,
255
+ rules=self.rules,
256
+ )
257
+
258
+ def with_rules(
259
+ self,
260
+ rules: tuple[SyncRule, ...],
261
+ ) -> ProfileConfiguration:
262
+ if self.local_root is None:
263
+ raise ConfigurationError("cannot change rules for an unmapped profile")
264
+ return ProfileConfiguration(
265
+ host=self.host,
266
+ remote_root=self.remote_root,
267
+ port=self.port,
268
+ username_env=self.username_env,
269
+ password_env=self.password_env,
270
+ type=self.type,
271
+ local_root=self.local_root,
272
+ rules=rules,
273
+ )
274
+
275
+ def remote_path_for(self, local_path: Path) -> str:
276
+ if self.local_root is None:
277
+ raise ConfigurationError("profile has no local root mapping")
278
+ try:
279
+ relative = local_path.relative_to(Path(self.local_root))
280
+ except ValueError as error:
281
+ raise ConfigurationError(
282
+ f"local path '{local_path}' is outside profile root '{self.local_root}'"
283
+ ) from error
284
+ if not relative.parts:
285
+ return self.remote_root
286
+ return str(PurePosixPath(self.remote_root) / PurePosixPath(*relative.parts))
287
+
288
+ def to_dict(self) -> dict[str, Any]:
289
+ return {
290
+ "type": self.type,
291
+ "host": self.host,
292
+ "remote_root": self.remote_root,
293
+ "port": self.port,
294
+ "username_env": self.username_env,
295
+ "password_env": self.password_env,
296
+ "local_root": self.local_root,
297
+ "rules": [rule.to_dict() for rule in self.rules],
298
+ }
299
+
300
+ @classmethod
301
+ def from_dict(cls, value: Any) -> ProfileConfiguration:
302
+ if not isinstance(value, dict):
303
+ raise ConfigurationError("profile configuration must be an object")
304
+ expected = {
305
+ "type",
306
+ "host",
307
+ "remote_root",
308
+ "port",
309
+ "username_env",
310
+ "password_env",
311
+ "local_root",
312
+ "rules",
313
+ }
314
+ unknown = set(value) - expected
315
+ if unknown:
316
+ raise ConfigurationError(
317
+ f"unknown profile configuration fields: {', '.join(sorted(unknown))}"
318
+ )
319
+ try:
320
+ rules = value["rules"]
321
+ if not isinstance(rules, list):
322
+ raise ConfigurationError("profile rules must be an array")
323
+ try:
324
+ decoded_rules = tuple(SyncRule.from_dict(rule) for rule in rules)
325
+ except RuleError as error:
326
+ raise ConfigurationError(str(error)) from error
327
+ return cls(
328
+ type=value["type"],
329
+ host=value["host"],
330
+ remote_root=value["remote_root"],
331
+ port=value["port"],
332
+ username_env=value["username_env"],
333
+ password_env=value["password_env"],
334
+ local_root=value["local_root"],
335
+ rules=decoded_rules,
336
+ )
337
+ except KeyError as error:
338
+ raise ConfigurationError(
339
+ f"profile configuration is missing field: {error.args[0]}"
340
+ ) from error
341
+
342
+
343
+ @dataclass
344
+ class ApplicationConfiguration:
345
+ profiles: dict[str, ProfileConfiguration] = field(default_factory=dict)
346
+
347
+ def __post_init__(self) -> None:
348
+ for name in self.profiles:
349
+ validate_profile_name(name)
350
+ mapped = [
351
+ (name, Path(profile.local_root))
352
+ for name, profile in self.profiles.items()
353
+ if profile.local_root is not None
354
+ ]
355
+ for index, (name, root) in enumerate(mapped):
356
+ for other_name, other_root in mapped[:index]:
357
+ if _paths_overlap(root, other_root):
358
+ raise ConfigurationError(
359
+ f"profile '{name}' local root '{root}' overlaps profile "
360
+ f"'{other_name}' root '{other_root}'"
361
+ )
362
+
363
+ def set_profile_roots(
364
+ self,
365
+ profile_name: str,
366
+ *,
367
+ local_root: str,
368
+ remote_root: str,
369
+ ) -> None:
370
+ profile = self.profiles[profile_name]
371
+ root = Path(local_root)
372
+ for other_name, other in self.profiles.items():
373
+ if other_name == profile_name or other.local_root is None:
374
+ continue
375
+ other_root = Path(other.local_root)
376
+ if _paths_overlap(root, other_root):
377
+ raise ConfigurationError(
378
+ f"local root '{root}' for profile '{profile_name}' overlaps "
379
+ f"profile '{other_name}' root '{other_root}'"
380
+ )
381
+ self.profiles[profile_name] = profile.with_roots(
382
+ local_root=local_root,
383
+ remote_root=remote_root,
384
+ )
385
+
386
+ def append_rules(
387
+ self,
388
+ profile_name: str,
389
+ action: RuleAction,
390
+ patterns: tuple[str, ...],
391
+ *,
392
+ target: RuleTarget = "local",
393
+ base_rules: tuple[SyncRule, ...] = (),
394
+ ) -> RuleUpdate:
395
+ profile = self.profiles[profile_name]
396
+ rules, update = _append_rules(
397
+ profile.rules,
398
+ action,
399
+ patterns,
400
+ target=target,
401
+ base_rules=base_rules,
402
+ gitignores=GitIgnores(Path(profile.local_root))
403
+ if profile.local_root
404
+ else None,
405
+ )
406
+ self.profiles[profile_name] = profile.with_rules(rules)
407
+ return update
408
+
409
+ def remove_rule(self, profile_name: str, rule_id: int) -> SyncRule:
410
+ profile = self.profiles[profile_name]
411
+ remaining, removed = _remove_rule(
412
+ profile.rules,
413
+ rule_id,
414
+ scope=f"profile '{profile_name}'",
415
+ )
416
+ self.profiles[profile_name] = profile.with_rules(remaining)
417
+ return removed
418
+
419
+ def profile_for_path(
420
+ self, local_path: Path
421
+ ) -> tuple[str, ProfileConfiguration] | None:
422
+ for name, profile in sorted(self.profiles.items()):
423
+ if profile.local_root is None:
424
+ continue
425
+ root = Path(profile.local_root)
426
+ if local_path == root or root in local_path.parents:
427
+ return name, profile
428
+ return None
429
+
430
+ def to_dict(self) -> dict[str, Any]:
431
+ return {
432
+ "version": CONFIG_VERSION,
433
+ "profiles": {
434
+ name: profile.to_dict()
435
+ for name, profile in sorted(self.profiles.items())
436
+ },
437
+ }
438
+
439
+ @classmethod
440
+ def from_dict(cls, value: Any) -> ApplicationConfiguration:
441
+ if not isinstance(value, dict):
442
+ raise ConfigurationError("configuration document must be an object")
443
+ if value.get("version") != CONFIG_VERSION:
444
+ raise ConfigurationError(
445
+ f"config version mismatch (found {value.get('version')!r}, "
446
+ f"expected {CONFIG_VERSION}); configuration schema changed—"
447
+ "recreate config"
448
+ )
449
+ if set(value) != {"version", "profiles"}:
450
+ raise ConfigurationError("configuration has unknown or missing fields")
451
+ profiles_value = value["profiles"]
452
+ if not isinstance(profiles_value, dict):
453
+ raise ConfigurationError("profiles must be an object")
454
+ profiles = {
455
+ validate_profile_name(name): ProfileConfiguration.from_dict(profile)
456
+ for name, profile in profiles_value.items()
457
+ }
458
+ return cls(profiles=profiles)
459
+
460
+
461
+ @dataclass(frozen=True)
462
+ class GlobalRuleConfiguration:
463
+ rules: tuple[SyncRule, ...] = DEFAULT_GLOBAL_RULES
464
+
465
+ def __post_init__(self) -> None:
466
+ object.__setattr__(
467
+ self,
468
+ "rules",
469
+ _validate_rule_sequence(self.rules),
470
+ )
471
+
472
+ def with_appended_rules(
473
+ self,
474
+ action: RuleAction,
475
+ patterns: tuple[str, ...],
476
+ *,
477
+ target: RuleTarget = "local",
478
+ ) -> tuple[GlobalRuleConfiguration, RuleUpdate]:
479
+ rules, update = _append_rules(
480
+ self.rules,
481
+ action,
482
+ patterns,
483
+ target=target,
484
+ )
485
+ return GlobalRuleConfiguration(rules), update
486
+
487
+ def without_rule(
488
+ self,
489
+ rule_id: int,
490
+ ) -> tuple[GlobalRuleConfiguration, SyncRule]:
491
+ rules, removed = _remove_rule(
492
+ self.rules,
493
+ rule_id,
494
+ scope="global synchronization policy",
495
+ )
496
+ return GlobalRuleConfiguration(rules), removed
497
+
498
+ def to_dict(self) -> dict[str, Any]:
499
+ return {
500
+ "version": GLOBAL_RULES_VERSION,
501
+ "rules": [rule.to_dict() for rule in self.rules],
502
+ }
503
+
504
+ @classmethod
505
+ def from_dict(cls, value: Any) -> GlobalRuleConfiguration:
506
+ if not isinstance(value, dict):
507
+ raise ConfigurationError("global rules document must be an object")
508
+ if value.get("version") != GLOBAL_RULES_VERSION:
509
+ raise ConfigurationError(
510
+ "global rules version mismatch "
511
+ f"(found {value.get('version')!r}, expected {GLOBAL_RULES_VERSION})"
512
+ )
513
+ if set(value) != {"version", "rules"}:
514
+ raise ConfigurationError(
515
+ "global rules document has unknown or missing fields"
516
+ )
517
+ rules = value["rules"]
518
+ if not isinstance(rules, list):
519
+ raise ConfigurationError("global rules must be an array")
520
+ try:
521
+ decoded = tuple(SyncRule.from_dict(rule) for rule in rules)
522
+ except RuleError as error:
523
+ raise ConfigurationError(str(error)) from error
524
+ return cls(decoded)
525
+
526
+
527
+ class ConfigurationStore:
528
+ def __init__(self, path: Path | None = None) -> None:
529
+ self.path = path or Path.home() / ".hlsync" / "configs.json"
530
+
531
+ def load(self) -> ApplicationConfiguration:
532
+ if not self.path.exists():
533
+ return ApplicationConfiguration()
534
+ try:
535
+ with self.path.open(encoding="utf-8") as handle:
536
+ document = json.load(handle)
537
+ except (OSError, json.JSONDecodeError) as error:
538
+ raise ConfigurationError(
539
+ f"could not read configuration file {self.path}: {error}"
540
+ ) from error
541
+ return ApplicationConfiguration.from_dict(document)
542
+
543
+ def save(self, configuration: ApplicationConfiguration) -> None:
544
+ try:
545
+ write_json_atomic(self.path, configuration.to_dict())
546
+ except OSError as error:
547
+ raise ConfigurationError(
548
+ f"could not write configuration file {self.path}: {error}"
549
+ ) from error
550
+
551
+
552
+ class GlobalRuleStore:
553
+ def __init__(self, path: Path | None = None) -> None:
554
+ self.path = path or Path.home() / ".hlsync" / "rules.json"
555
+
556
+ def load(self) -> GlobalRuleConfiguration:
557
+ if not self.path.exists():
558
+ configuration = GlobalRuleConfiguration()
559
+ self.save(configuration)
560
+ return configuration
561
+ try:
562
+ with self.path.open(encoding="utf-8") as handle:
563
+ document = json.load(handle)
564
+ except (OSError, json.JSONDecodeError) as error:
565
+ raise ConfigurationError(
566
+ f"could not read global rules file {self.path}: {error}"
567
+ ) from error
568
+ return GlobalRuleConfiguration.from_dict(document)
569
+
570
+ def save(self, configuration: GlobalRuleConfiguration) -> None:
571
+ try:
572
+ write_json_atomic(self.path, configuration.to_dict())
573
+ except OSError as error:
574
+ raise ConfigurationError(
575
+ f"could not write global rules file {self.path}: {error}"
576
+ ) from error
hlsync/gitignore.py ADDED
@@ -0,0 +1,59 @@
1
+ """Repository-scoped Git ignores, loaded lazily for profile-relative paths."""
2
+
3
+ from pathlib import Path, PurePosixPath
4
+
5
+ from pathspec import GitIgnoreSpec
6
+
7
+
8
+ class GitIgnores:
9
+ def __init__(self, root: Path) -> None:
10
+ self.root = next(
11
+ (
12
+ directory
13
+ for directory in (root, *root.parents)
14
+ if (directory / ".git").is_dir() or (directory / ".git").is_file()
15
+ ),
16
+ root,
17
+ )
18
+ self._profile_prefix = root.relative_to(self.root)
19
+ self._specs: dict[PurePosixPath, GitIgnoreSpec] = {}
20
+
21
+ def _spec(self, directory: PurePosixPath) -> GitIgnoreSpec:
22
+ if directory not in self._specs:
23
+ source = self.root.joinpath(*directory.parts, ".gitignore")
24
+ # Like Git, do not follow symlinked ignore files.
25
+ if source.is_symlink():
26
+ lines = []
27
+ else:
28
+ try:
29
+ lines = source.read_text(
30
+ encoding="utf-8", errors="surrogateescape"
31
+ ).splitlines()
32
+ except FileNotFoundError:
33
+ lines = []
34
+ self._specs[directory] = GitIgnoreSpec.from_lines(lines)
35
+ return self._specs[directory]
36
+
37
+ def excludes(self, path: str, *, is_directory: bool = False) -> bool:
38
+ parts = (PurePosixPath(self._profile_prefix) / path).parts
39
+ scopes: list[PurePosixPath] = []
40
+ for depth in range(len(parts)):
41
+ parent = PurePosixPath(*parts[:depth])
42
+ if self.root.joinpath(*parent.parts).is_symlink():
43
+ return False
44
+ scopes.append(parent)
45
+ directory = depth < len(parts) - 1 or is_directory
46
+ ignored = False
47
+ for scope in scopes:
48
+ candidate = PurePosixPath(
49
+ *parts[len(scope.parts) : depth + 1]
50
+ ).as_posix()
51
+ result = self._spec(scope).check_file(
52
+ candidate + ("/" if directory else "")
53
+ )
54
+ if result.include is not None:
55
+ ignored = result.include
56
+ # A negation inside an ignored parent cannot reopen that parent.
57
+ if ignored:
58
+ return True
59
+ return False
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+
6
+ class PatternOperandError(ValueError):
7
+ """Raised when shared CLI path-pattern operands are malformed."""
8
+
9
+
10
+ def add_pattern_operands(
11
+ parser: argparse.ArgumentParser,
12
+ *,
13
+ required: bool,
14
+ help_text: str,
15
+ metavar: str = "pattern",
16
+ ) -> None:
17
+ parser.add_argument(
18
+ "pattern_operands",
19
+ nargs="+" if required else "*",
20
+ metavar=metavar,
21
+ help=help_text,
22
+ )
23
+
24
+
25
+ def normalize_pattern_operands(arguments: argparse.Namespace) -> tuple[str, ...]:
26
+ patterns: list[str] = []
27
+ for operand in arguments.pattern_operands:
28
+ for value in operand.split(","):
29
+ normalized = value.strip()
30
+ if not normalized:
31
+ raise PatternOperandError("path patterns cannot be empty")
32
+ patterns.append(normalized)
33
+ return tuple(patterns)