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/__init__.py +5 -0
- hlsync/__main__.py +4 -0
- hlsync/_version.py +2 -0
- hlsync/cli.py +2909 -0
- hlsync/comparison.py +278 -0
- hlsync/config.py +576 -0
- hlsync/gitignore.py +59 -0
- hlsync/pattern_operands.py +33 -0
- hlsync/progress.py +57 -0
- hlsync/rules.py +276 -0
- hlsync/selection.py +159 -0
- hlsync/snapshot.py +193 -0
- hlsync/storage.py +29 -0
- hlsync/transfer.py +465 -0
- hlsync/transport.py +761 -0
- hlsync-1.17.2.dist-info/METADATA +687 -0
- hlsync-1.17.2.dist-info/RECORD +21 -0
- hlsync-1.17.2.dist-info/WHEEL +5 -0
- hlsync-1.17.2.dist-info/entry_points.txt +2 -0
- hlsync-1.17.2.dist-info/licenses/LICENSE +106 -0
- hlsync-1.17.2.dist-info/top_level.txt +1 -0
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)
|