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/progress.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Directory progress based on work discovered by the remote traversal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
from typing import TextIO
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DirectoryReadProgress:
|
|
12
|
+
def __init__(self, output: TextIO) -> None:
|
|
13
|
+
self.output = output
|
|
14
|
+
self.terminal = output.isatty() and os.environ.get("TERM") != "dumb"
|
|
15
|
+
self.directory = "."
|
|
16
|
+
self.completed = 0
|
|
17
|
+
self.discovered = 1
|
|
18
|
+
|
|
19
|
+
def _draw(self) -> None:
|
|
20
|
+
text = (
|
|
21
|
+
f" {self.completed}/{self.discovered} discovered directories read"
|
|
22
|
+
f" · {self.directory}/"
|
|
23
|
+
)
|
|
24
|
+
if self.terminal:
|
|
25
|
+
width = max(1, shutil.get_terminal_size().columns - 1)
|
|
26
|
+
print(f"\r\033[2K{text[:width]}", end="", file=self.output, flush=True)
|
|
27
|
+
else:
|
|
28
|
+
print(text, file=self.output, flush=True)
|
|
29
|
+
|
|
30
|
+
def __enter__(self) -> DirectoryReadProgress:
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def reading(self, directory: PurePosixPath) -> None:
|
|
34
|
+
self.directory = directory.as_posix()
|
|
35
|
+
self._draw()
|
|
36
|
+
|
|
37
|
+
def counts(self, completed: int, discovered: int) -> None:
|
|
38
|
+
self.completed = completed
|
|
39
|
+
self.discovered = discovered
|
|
40
|
+
if self.terminal:
|
|
41
|
+
self._draw()
|
|
42
|
+
|
|
43
|
+
def message(self, message: str) -> None:
|
|
44
|
+
if self.terminal:
|
|
45
|
+
print("\r\033[2K", end="", file=self.output)
|
|
46
|
+
print(f" {message}", file=self.output, flush=True)
|
|
47
|
+
if self.terminal:
|
|
48
|
+
self._draw()
|
|
49
|
+
|
|
50
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
51
|
+
if self.terminal:
|
|
52
|
+
print("\r\033[2K", end="", file=self.output, flush=True)
|
|
53
|
+
if exc_type is None:
|
|
54
|
+
print(
|
|
55
|
+
f" Read {self.completed}/{self.discovered} directories.",
|
|
56
|
+
file=self.output, flush=True,
|
|
57
|
+
)
|
hlsync/rules.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from hlsync.gitignore import GitIgnores
|
|
9
|
+
from hlsync.selection import FileSelector, SelectionError
|
|
10
|
+
|
|
11
|
+
RuleAction = Literal["include", "exclude"]
|
|
12
|
+
RuleTarget = Literal["local", "remote"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RuleError(ValueError):
|
|
16
|
+
"""Raised when an HLSync synchronization rule is unsafe or malformed."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _relative_base(profile_root: Path, current_directory: Path) -> PurePosixPath:
|
|
20
|
+
try:
|
|
21
|
+
relative = current_directory.relative_to(profile_root)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return PurePosixPath()
|
|
24
|
+
return PurePosixPath(*relative.parts)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def expand_path_operands(
|
|
28
|
+
values: tuple[str, ...],
|
|
29
|
+
*,
|
|
30
|
+
profile_root: Path,
|
|
31
|
+
current_directory: Path,
|
|
32
|
+
) -> tuple[str, ...]:
|
|
33
|
+
"""Expand wildcard operands to the local paths they currently match."""
|
|
34
|
+
try:
|
|
35
|
+
current_directory.relative_to(profile_root)
|
|
36
|
+
except ValueError:
|
|
37
|
+
expansion_root = profile_root
|
|
38
|
+
else:
|
|
39
|
+
expansion_root = current_directory
|
|
40
|
+
expanded: list[str] = []
|
|
41
|
+
for value in values:
|
|
42
|
+
supplied = PurePosixPath(value)
|
|
43
|
+
if supplied.is_absolute() or ".." in supplied.parts:
|
|
44
|
+
raise RuleError(
|
|
45
|
+
"rule paths must be relative to the profile or current directory"
|
|
46
|
+
)
|
|
47
|
+
if "*" not in value:
|
|
48
|
+
expanded.append(value)
|
|
49
|
+
continue
|
|
50
|
+
matches = glob.glob(
|
|
51
|
+
value,
|
|
52
|
+
root_dir=expansion_root,
|
|
53
|
+
recursive=True,
|
|
54
|
+
include_hidden=False,
|
|
55
|
+
)
|
|
56
|
+
if not matches:
|
|
57
|
+
raise RuleError(f"path expression '{value}' matched no local paths")
|
|
58
|
+
expanded.extend(PurePosixPath(match).as_posix() for match in sorted(matches))
|
|
59
|
+
return tuple(dict.fromkeys(expanded))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def patterns_from_operands(
|
|
63
|
+
values: tuple[str, ...],
|
|
64
|
+
*,
|
|
65
|
+
profile_root: Path,
|
|
66
|
+
current_directory: Path,
|
|
67
|
+
) -> tuple[str, ...]:
|
|
68
|
+
"""Translate CLI operands into profile-rooted HLSync patterns."""
|
|
69
|
+
base = _relative_base(profile_root, current_directory)
|
|
70
|
+
normalized: list[str] = []
|
|
71
|
+
for value in values:
|
|
72
|
+
if not isinstance(value, str) or not value.strip():
|
|
73
|
+
raise RuleError("rule patterns must be non-empty strings")
|
|
74
|
+
pattern = value.strip()
|
|
75
|
+
supplied = PurePosixPath(pattern)
|
|
76
|
+
if supplied.is_absolute() or ".." in supplied.parts:
|
|
77
|
+
raise RuleError(
|
|
78
|
+
"rule patterns must be relative to the profile or current directory"
|
|
79
|
+
)
|
|
80
|
+
rooted = base / supplied
|
|
81
|
+
local_path = profile_root.joinpath(*rooted.parts)
|
|
82
|
+
is_directory = pattern.endswith("/") or (
|
|
83
|
+
"*" not in pattern and local_path.is_dir() and not local_path.is_symlink()
|
|
84
|
+
)
|
|
85
|
+
if is_directory:
|
|
86
|
+
rooted = rooted / "**"
|
|
87
|
+
candidate = rooted.as_posix()
|
|
88
|
+
try:
|
|
89
|
+
candidate = FileSelector(candidate).pattern
|
|
90
|
+
except SelectionError as error:
|
|
91
|
+
raise RuleError(str(error)) from error
|
|
92
|
+
normalized.append(candidate)
|
|
93
|
+
return tuple(normalized)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def patterns_from_global_operands(
|
|
97
|
+
values: tuple[str, ...],
|
|
98
|
+
*,
|
|
99
|
+
trailing_slash_tree: bool = True,
|
|
100
|
+
) -> tuple[str, ...]:
|
|
101
|
+
"""Normalize reusable patterns rooted at every profile's local root."""
|
|
102
|
+
normalized: list[str] = []
|
|
103
|
+
for value in values:
|
|
104
|
+
if not isinstance(value, str) or not value.strip():
|
|
105
|
+
raise RuleError("global rule patterns must be non-empty strings")
|
|
106
|
+
pattern = value.strip()
|
|
107
|
+
supplied = PurePosixPath(
|
|
108
|
+
pattern if trailing_slash_tree else pattern.rstrip("/")
|
|
109
|
+
)
|
|
110
|
+
if supplied.is_absolute() or ".." in supplied.parts:
|
|
111
|
+
raise RuleError("global rules must be relative to the profile root")
|
|
112
|
+
if trailing_slash_tree and pattern.endswith("/"):
|
|
113
|
+
supplied = supplied / "**"
|
|
114
|
+
try:
|
|
115
|
+
normalized.append(FileSelector(supplied.as_posix()).pattern)
|
|
116
|
+
except SelectionError as error:
|
|
117
|
+
raise RuleError(str(error)) from error
|
|
118
|
+
return tuple(normalized)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def patterns_from_remote_operands(
|
|
122
|
+
values: tuple[str, ...],
|
|
123
|
+
*,
|
|
124
|
+
profile_root: Path,
|
|
125
|
+
current_directory: Path,
|
|
126
|
+
) -> tuple[str, ...]:
|
|
127
|
+
"""Normalize declarative remote paths without consulting either filesystem."""
|
|
128
|
+
base = _relative_base(profile_root, current_directory)
|
|
129
|
+
normalized: list[str] = []
|
|
130
|
+
for value in values:
|
|
131
|
+
if not isinstance(value, str) or not value.strip():
|
|
132
|
+
raise RuleError("remote rule paths must be non-empty strings")
|
|
133
|
+
pattern = value.strip()
|
|
134
|
+
supplied = PurePosixPath(pattern.rstrip("/"))
|
|
135
|
+
if supplied.is_absolute() or not supplied.parts or ".." in supplied.parts:
|
|
136
|
+
raise RuleError(
|
|
137
|
+
"remote rule paths must be relative to the profile or current directory"
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
normalized.append(FileSelector((base / supplied).as_posix()).pattern)
|
|
141
|
+
except SelectionError as error:
|
|
142
|
+
raise RuleError(str(error)) from error
|
|
143
|
+
return tuple(normalized)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class SyncRule:
|
|
148
|
+
id: int
|
|
149
|
+
action: RuleAction
|
|
150
|
+
pattern: str
|
|
151
|
+
target: RuleTarget = "local"
|
|
152
|
+
|
|
153
|
+
def __post_init__(self) -> None:
|
|
154
|
+
if isinstance(self.id, bool) or not isinstance(self.id, int) or self.id < 1:
|
|
155
|
+
raise RuleError("rule id must be a positive integer")
|
|
156
|
+
if not isinstance(self.action, str) or self.action not in {
|
|
157
|
+
"include",
|
|
158
|
+
"exclude",
|
|
159
|
+
}:
|
|
160
|
+
raise RuleError("rule action must be 'include' or 'exclude'")
|
|
161
|
+
if not isinstance(self.pattern, str):
|
|
162
|
+
raise RuleError("rule pattern must be a string")
|
|
163
|
+
if self.target not in {"local", "remote"}:
|
|
164
|
+
raise RuleError("rule target must be 'local' or 'remote'")
|
|
165
|
+
try:
|
|
166
|
+
normalized = FileSelector(self.pattern).pattern
|
|
167
|
+
except SelectionError as error:
|
|
168
|
+
raise RuleError(str(error)) from error
|
|
169
|
+
object.__setattr__(self, "pattern", normalized)
|
|
170
|
+
object.__setattr__(self, "_selector", FileSelector(normalized))
|
|
171
|
+
|
|
172
|
+
def matches(self, profile_relative_path: str) -> bool:
|
|
173
|
+
return self._selector.matches(profile_relative_path)
|
|
174
|
+
|
|
175
|
+
def may_match_descendant(self, profile_relative_directory: str) -> bool:
|
|
176
|
+
return self._selector.may_match_descendant(profile_relative_directory)
|
|
177
|
+
|
|
178
|
+
def to_dict(self) -> dict[str, Any]:
|
|
179
|
+
value = {
|
|
180
|
+
"id": self.id,
|
|
181
|
+
"action": self.action,
|
|
182
|
+
"pattern": self.pattern,
|
|
183
|
+
}
|
|
184
|
+
if self.target == "remote":
|
|
185
|
+
value["target"] = self.target
|
|
186
|
+
return value
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def from_dict(cls, value: Any) -> SyncRule:
|
|
190
|
+
required = {"id", "action", "pattern"}
|
|
191
|
+
if (
|
|
192
|
+
not isinstance(value, dict)
|
|
193
|
+
or not required.issubset(value)
|
|
194
|
+
or set(value) - required != ({"target"} if "target" in value else set())
|
|
195
|
+
):
|
|
196
|
+
raise RuleError(
|
|
197
|
+
"each rule must contain id, action, and pattern, with only an "
|
|
198
|
+
"optional target"
|
|
199
|
+
)
|
|
200
|
+
return cls(
|
|
201
|
+
id=value["id"],
|
|
202
|
+
action=value["action"],
|
|
203
|
+
pattern=value["pattern"],
|
|
204
|
+
target=value.get("target", "local"),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True)
|
|
209
|
+
class RuleSet:
|
|
210
|
+
rules: tuple[SyncRule, ...] = ()
|
|
211
|
+
gitignores: GitIgnores | None = None
|
|
212
|
+
|
|
213
|
+
def __post_init__(self) -> None:
|
|
214
|
+
if any(not isinstance(rule, SyncRule) for rule in self.rules):
|
|
215
|
+
raise RuleError("rules must contain structured synchronization rules")
|
|
216
|
+
ids = tuple(rule.id for rule in self.rules)
|
|
217
|
+
if len(set(ids)) != len(ids):
|
|
218
|
+
raise RuleError("rule ids must be unique")
|
|
219
|
+
if ids != tuple(sorted(ids)):
|
|
220
|
+
raise RuleError("rules must be ordered by increasing id")
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def layered(
|
|
224
|
+
cls, *layers: tuple[SyncRule, ...], gitignores: GitIgnores | None = None
|
|
225
|
+
) -> RuleSet:
|
|
226
|
+
return cls(
|
|
227
|
+
tuple(
|
|
228
|
+
SyncRule(index, rule.action, rule.pattern, rule.target)
|
|
229
|
+
for index, rule in enumerate(
|
|
230
|
+
(rule for layer in layers for rule in layer),
|
|
231
|
+
start=1,
|
|
232
|
+
)
|
|
233
|
+
),
|
|
234
|
+
gitignores=gitignores,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def excludes(
|
|
238
|
+
self,
|
|
239
|
+
relative_path: str,
|
|
240
|
+
*,
|
|
241
|
+
target: RuleTarget = "local",
|
|
242
|
+
is_directory: bool = False,
|
|
243
|
+
) -> bool:
|
|
244
|
+
profile_relative_path = relative_path
|
|
245
|
+
path = PurePosixPath(profile_relative_path)
|
|
246
|
+
if path.is_absolute() or not path.parts or ".." in path.parts:
|
|
247
|
+
raise RuleError("evaluated paths must be non-empty profile-relative paths")
|
|
248
|
+
candidate = path.as_posix()
|
|
249
|
+
winner = next(
|
|
250
|
+
(
|
|
251
|
+
rule
|
|
252
|
+
for rule in reversed(self.rules)
|
|
253
|
+
if rule.target == target and rule.matches(candidate)
|
|
254
|
+
),
|
|
255
|
+
None,
|
|
256
|
+
)
|
|
257
|
+
if winner is not None:
|
|
258
|
+
return winner.action == "exclude"
|
|
259
|
+
return bool(
|
|
260
|
+
target == "local"
|
|
261
|
+
and self.gitignores is not None
|
|
262
|
+
and self.gitignores.excludes(candidate, is_directory=is_directory)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def may_include_descendant(
|
|
266
|
+
self,
|
|
267
|
+
relative_directory: str,
|
|
268
|
+
*,
|
|
269
|
+
target: RuleTarget = "local",
|
|
270
|
+
) -> bool:
|
|
271
|
+
return any(
|
|
272
|
+
rule.action == "include"
|
|
273
|
+
and rule.target == target
|
|
274
|
+
and rule.may_match_descendant(relative_directory)
|
|
275
|
+
for rule in self.rules
|
|
276
|
+
)
|
hlsync/selection.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from fnmatch import fnmatchcase
|
|
5
|
+
from functools import cache
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SelectionError(ValueError):
|
|
10
|
+
"""Raised when a file selector is unsafe or matches no paths."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@cache
|
|
14
|
+
def _matches_parts(pattern: tuple[str, ...], path: tuple[str, ...]) -> bool:
|
|
15
|
+
if not pattern:
|
|
16
|
+
return not path
|
|
17
|
+
head, *tail = pattern
|
|
18
|
+
if head == "**":
|
|
19
|
+
return _matches_parts(tuple(tail), path) or (
|
|
20
|
+
bool(path) and _matches_parts(pattern, path[1:])
|
|
21
|
+
)
|
|
22
|
+
return bool(path) and fnmatchcase(path[0], head) and _matches_parts(
|
|
23
|
+
tuple(tail), path[1:]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cache
|
|
28
|
+
def _can_match_descendant(
|
|
29
|
+
pattern: tuple[str, ...],
|
|
30
|
+
directory: tuple[str, ...],
|
|
31
|
+
) -> bool:
|
|
32
|
+
if not directory:
|
|
33
|
+
return bool(pattern)
|
|
34
|
+
if not pattern:
|
|
35
|
+
return False
|
|
36
|
+
head, *tail = pattern
|
|
37
|
+
if head == "**":
|
|
38
|
+
return _can_match_descendant(tuple(tail), directory) or (
|
|
39
|
+
_can_match_descendant(pattern, directory[1:])
|
|
40
|
+
)
|
|
41
|
+
return fnmatchcase(directory[0], head) and _can_match_descendant(
|
|
42
|
+
tuple(tail), directory[1:]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class FileSelector:
|
|
48
|
+
pattern: str
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
path = PurePosixPath(self.pattern)
|
|
52
|
+
if path.is_absolute() or not path.parts or ".." in path.parts:
|
|
53
|
+
raise SelectionError(
|
|
54
|
+
"file selector must be a non-empty relative path without '..'"
|
|
55
|
+
)
|
|
56
|
+
if any(character in self.pattern for character in "?["):
|
|
57
|
+
raise SelectionError("file selector supports only '*' and '**' wildcards")
|
|
58
|
+
if any("**" in part and part != "**" for part in path.parts):
|
|
59
|
+
raise SelectionError("'**' must occupy an entire path segment")
|
|
60
|
+
object.__setattr__(self, "pattern", path.as_posix())
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_argument(
|
|
64
|
+
cls,
|
|
65
|
+
value: str,
|
|
66
|
+
*,
|
|
67
|
+
profile_root: Path,
|
|
68
|
+
current_directory: Path,
|
|
69
|
+
) -> FileSelector:
|
|
70
|
+
if not value:
|
|
71
|
+
raise SelectionError("file selector cannot be empty")
|
|
72
|
+
try:
|
|
73
|
+
current_relative = current_directory.relative_to(profile_root)
|
|
74
|
+
except ValueError:
|
|
75
|
+
base = PurePosixPath()
|
|
76
|
+
else:
|
|
77
|
+
base = PurePosixPath(*current_relative.parts)
|
|
78
|
+
supplied = PurePosixPath(value)
|
|
79
|
+
if supplied.is_absolute() or ".." in supplied.parts:
|
|
80
|
+
raise SelectionError(
|
|
81
|
+
"file selector must be relative to the profile or current directory"
|
|
82
|
+
)
|
|
83
|
+
return cls((base / supplied).as_posix())
|
|
84
|
+
|
|
85
|
+
def matches(self, profile_relative_path: str) -> bool:
|
|
86
|
+
path = PurePosixPath(profile_relative_path)
|
|
87
|
+
if path.is_absolute() or ".." in path.parts:
|
|
88
|
+
raise SelectionError("selected paths must remain inside the profile")
|
|
89
|
+
return _matches_parts(PurePosixPath(self.pattern).parts, path.parts)
|
|
90
|
+
|
|
91
|
+
def may_match_descendant(self, profile_relative_directory: str) -> bool:
|
|
92
|
+
directory = PurePosixPath(profile_relative_directory)
|
|
93
|
+
if directory.is_absolute() or ".." in directory.parts:
|
|
94
|
+
raise SelectionError("selected paths must remain inside the profile")
|
|
95
|
+
return _can_match_descendant(
|
|
96
|
+
PurePosixPath(self.pattern).parts,
|
|
97
|
+
directory.parts,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class FileSelectorSet:
|
|
103
|
+
selectors: tuple[FileSelector, ...]
|
|
104
|
+
|
|
105
|
+
def __post_init__(self) -> None:
|
|
106
|
+
if not self.selectors:
|
|
107
|
+
raise SelectionError("file selector set cannot be empty")
|
|
108
|
+
unique = {selector.pattern: selector for selector in self.selectors}
|
|
109
|
+
object.__setattr__(
|
|
110
|
+
self,
|
|
111
|
+
"selectors",
|
|
112
|
+
tuple(unique[pattern] for pattern in sorted(unique)),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def pattern(self) -> str:
|
|
117
|
+
return ", ".join(selector.pattern for selector in self.selectors)
|
|
118
|
+
|
|
119
|
+
def matches(self, profile_relative_path: str) -> bool:
|
|
120
|
+
return any(
|
|
121
|
+
selector.matches(profile_relative_path) for selector in self.selectors
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def may_match_descendant(self, profile_relative_directory: str) -> bool:
|
|
125
|
+
return any(
|
|
126
|
+
selector.may_match_descendant(profile_relative_directory)
|
|
127
|
+
for selector in self.selectors
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
FileSelection = FileSelector | FileSelectorSet
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class DirectoryContentsSelection:
|
|
136
|
+
"""Select operands while treating matched directories as containers."""
|
|
137
|
+
|
|
138
|
+
traversal: FileSelection
|
|
139
|
+
containers: FileSelection | None = None
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def pattern(self) -> str:
|
|
143
|
+
return self.traversal.pattern
|
|
144
|
+
|
|
145
|
+
def matches(self, profile_relative_path: str) -> bool:
|
|
146
|
+
return self.traversal.matches(profile_relative_path)
|
|
147
|
+
|
|
148
|
+
def may_match_descendant(self, profile_relative_directory: str) -> bool:
|
|
149
|
+
return self.traversal.may_match_descendant(profile_relative_directory)
|
|
150
|
+
|
|
151
|
+
def includes_result(self, profile_relative_path: str, *, directory: bool) -> bool:
|
|
152
|
+
return not (
|
|
153
|
+
directory
|
|
154
|
+
and self.containers is not None
|
|
155
|
+
and self.containers.matches(profile_relative_path)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
FileSelection = FileSelector | FileSelectorSet | DirectoryContentsSelection
|
hlsync/snapshot.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from hlsync.rules import RuleSet
|
|
10
|
+
from hlsync.selection import FileSelection
|
|
11
|
+
|
|
12
|
+
EntryKind = Literal["directory", "file", "symlink"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SnapshotError(RuntimeError):
|
|
16
|
+
"""Raised when a deterministic tree snapshot cannot be produced."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class TreeEntry:
|
|
21
|
+
path: str
|
|
22
|
+
kind: EntryKind
|
|
23
|
+
size: int | None = None
|
|
24
|
+
modified_ns: int | None = None
|
|
25
|
+
timestamp_precision_ns: int | None = None
|
|
26
|
+
excluded: bool = False
|
|
27
|
+
remote_excluded: bool = False
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
path = PurePosixPath(self.path)
|
|
31
|
+
if path.is_absolute() or not path.parts or ".." in path.parts:
|
|
32
|
+
raise SnapshotError(f"snapshot path must be relative: {self.path!r}")
|
|
33
|
+
object.__setattr__(self, "path", path.as_posix())
|
|
34
|
+
if not isinstance(self.excluded, bool):
|
|
35
|
+
raise SnapshotError("snapshot exclusion state must be a boolean")
|
|
36
|
+
if not isinstance(self.remote_excluded, bool):
|
|
37
|
+
raise SnapshotError("remote exclusion state must be a boolean")
|
|
38
|
+
metadata = (self.size, self.modified_ns, self.timestamp_precision_ns)
|
|
39
|
+
if self.kind == "file":
|
|
40
|
+
if any(value is None for value in metadata):
|
|
41
|
+
raise SnapshotError(
|
|
42
|
+
f"file snapshot metadata is incomplete: {self.path!r}"
|
|
43
|
+
)
|
|
44
|
+
if self.size is not None and self.size < 0:
|
|
45
|
+
raise SnapshotError(f"file size cannot be negative: {self.path!r}")
|
|
46
|
+
if (
|
|
47
|
+
self.timestamp_precision_ns is not None
|
|
48
|
+
and self.timestamp_precision_ns <= 0
|
|
49
|
+
):
|
|
50
|
+
raise SnapshotError(
|
|
51
|
+
f"timestamp precision must be positive: {self.path!r}"
|
|
52
|
+
)
|
|
53
|
+
elif any(value is not None for value in metadata):
|
|
54
|
+
raise SnapshotError(
|
|
55
|
+
f"{self.kind} snapshot entry cannot have file metadata: "
|
|
56
|
+
f"{self.path!r}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class TreeSnapshot:
|
|
62
|
+
entries: tuple[TreeEntry, ...] = ()
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
ordered = tuple(sorted(self.entries, key=lambda entry: entry.path))
|
|
66
|
+
if len({entry.path for entry in ordered}) != len(ordered):
|
|
67
|
+
raise SnapshotError("snapshot contains duplicate paths")
|
|
68
|
+
object.__setattr__(self, "entries", ordered)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def snapshot_local(
|
|
72
|
+
root: Path,
|
|
73
|
+
rules: RuleSet,
|
|
74
|
+
selector: FileSelection | None = None,
|
|
75
|
+
*,
|
|
76
|
+
include_excluded: bool = False,
|
|
77
|
+
traverse_excluded: bool = False,
|
|
78
|
+
respect_remote_boundaries: bool = False,
|
|
79
|
+
directory_progress: Callable[[PurePosixPath], None] | None = None,
|
|
80
|
+
) -> TreeSnapshot:
|
|
81
|
+
if not root.is_dir():
|
|
82
|
+
raise SnapshotError(f"local root is not an accessible directory: {root}")
|
|
83
|
+
entries: list[TreeEntry] = []
|
|
84
|
+
|
|
85
|
+
def walk(relative_directory: PurePosixPath) -> None:
|
|
86
|
+
if directory_progress is not None:
|
|
87
|
+
directory_progress(relative_directory)
|
|
88
|
+
listing = list_local_directory(root, relative_directory, rules)
|
|
89
|
+
descendants: list[PurePosixPath] = []
|
|
90
|
+
for entry in listing.entries:
|
|
91
|
+
relative_path = entry.path
|
|
92
|
+
relative = PurePosixPath(relative_path)
|
|
93
|
+
kind = entry.kind
|
|
94
|
+
excluded = entry.excluded
|
|
95
|
+
visible = not excluded or include_excluded
|
|
96
|
+
selected = visible and (
|
|
97
|
+
selector is None or selector.matches(relative_path)
|
|
98
|
+
)
|
|
99
|
+
if selected:
|
|
100
|
+
entries.append(entry)
|
|
101
|
+
selection_may_descend = (
|
|
102
|
+
selector is None or selector.may_match_descendant(relative_path)
|
|
103
|
+
)
|
|
104
|
+
exclusion_may_descend = (
|
|
105
|
+
traverse_excluded
|
|
106
|
+
or not excluded
|
|
107
|
+
or rules.may_include_descendant(relative_path)
|
|
108
|
+
)
|
|
109
|
+
remote_may_descend = (
|
|
110
|
+
not respect_remote_boundaries or not entry.remote_excluded
|
|
111
|
+
)
|
|
112
|
+
if (
|
|
113
|
+
kind == "directory"
|
|
114
|
+
and selection_may_descend
|
|
115
|
+
and exclusion_may_descend
|
|
116
|
+
and remote_may_descend
|
|
117
|
+
):
|
|
118
|
+
descendants.append(relative)
|
|
119
|
+
for descendant in descendants:
|
|
120
|
+
walk(descendant)
|
|
121
|
+
|
|
122
|
+
walk(PurePosixPath())
|
|
123
|
+
return TreeSnapshot(tuple(entries))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def list_local_directory(
|
|
127
|
+
root: Path,
|
|
128
|
+
relative_directory: PurePosixPath,
|
|
129
|
+
rules: RuleSet,
|
|
130
|
+
) -> TreeSnapshot:
|
|
131
|
+
"""Inspect one directory and return all immediate children."""
|
|
132
|
+
directory = root / relative_directory
|
|
133
|
+
try:
|
|
134
|
+
with os.scandir(directory) as iterator:
|
|
135
|
+
children = sorted(iterator, key=lambda entry: entry.name)
|
|
136
|
+
except OSError as error:
|
|
137
|
+
raise SnapshotError(
|
|
138
|
+
f"could not read local directory '{directory}': {error}"
|
|
139
|
+
) from error
|
|
140
|
+
|
|
141
|
+
entries: list[TreeEntry] = []
|
|
142
|
+
for child in children:
|
|
143
|
+
relative = relative_directory / child.name
|
|
144
|
+
relative_path = relative.as_posix()
|
|
145
|
+
try:
|
|
146
|
+
if child.is_symlink():
|
|
147
|
+
kind: EntryKind = "symlink"
|
|
148
|
+
elif child.is_dir(follow_symlinks=False):
|
|
149
|
+
kind = "directory"
|
|
150
|
+
elif child.is_file(follow_symlinks=False):
|
|
151
|
+
kind = "file"
|
|
152
|
+
else:
|
|
153
|
+
raise SnapshotError(
|
|
154
|
+
f"unsupported local entry type: {directory / child.name}"
|
|
155
|
+
)
|
|
156
|
+
except OSError as error:
|
|
157
|
+
raise SnapshotError(
|
|
158
|
+
f"could not inspect local path '{directory / child.name}': {error}"
|
|
159
|
+
) from error
|
|
160
|
+
|
|
161
|
+
excluded = rules.excludes(relative_path, is_directory=kind == "directory")
|
|
162
|
+
remote_excluded = rules.excludes(
|
|
163
|
+
relative_path, target="remote", is_directory=kind == "directory"
|
|
164
|
+
)
|
|
165
|
+
if kind == "file":
|
|
166
|
+
try:
|
|
167
|
+
stat = child.stat(follow_symlinks=False)
|
|
168
|
+
except OSError as error:
|
|
169
|
+
raise SnapshotError(
|
|
170
|
+
f"could not read local file metadata '{directory / child.name}': "
|
|
171
|
+
f"{error}"
|
|
172
|
+
) from error
|
|
173
|
+
entries.append(
|
|
174
|
+
TreeEntry(
|
|
175
|
+
relative_path,
|
|
176
|
+
kind,
|
|
177
|
+
size=stat.st_size,
|
|
178
|
+
modified_ns=stat.st_mtime_ns,
|
|
179
|
+
timestamp_precision_ns=1,
|
|
180
|
+
excluded=excluded,
|
|
181
|
+
remote_excluded=remote_excluded,
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
entries.append(
|
|
186
|
+
TreeEntry(
|
|
187
|
+
relative_path,
|
|
188
|
+
kind,
|
|
189
|
+
excluded=excluded,
|
|
190
|
+
remote_excluded=remote_excluded,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return TreeSnapshot(tuple(entries))
|