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/comparison.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from hlsync.selection import FileSelection, SelectionError
|
|
8
|
+
from hlsync.snapshot import EntryKind, TreeEntry, TreeSnapshot
|
|
9
|
+
|
|
10
|
+
Direction = Literal["push", "pull"]
|
|
11
|
+
ComparisonState = Literal[
|
|
12
|
+
"identical",
|
|
13
|
+
"local-only",
|
|
14
|
+
"remote-only",
|
|
15
|
+
"changed",
|
|
16
|
+
"destination-newer",
|
|
17
|
+
"excluded",
|
|
18
|
+
"remote-excluded",
|
|
19
|
+
"type-conflict",
|
|
20
|
+
"symlink-conflict",
|
|
21
|
+
]
|
|
22
|
+
PlanAction = Literal[
|
|
23
|
+
"unchanged",
|
|
24
|
+
"skip",
|
|
25
|
+
"create-remote",
|
|
26
|
+
"upload",
|
|
27
|
+
"replace-remote",
|
|
28
|
+
"replace-local",
|
|
29
|
+
"delete-remote",
|
|
30
|
+
"conflict",
|
|
31
|
+
"excluded",
|
|
32
|
+
"untraversed",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ComparisonEntry:
|
|
38
|
+
path: str
|
|
39
|
+
state: ComparisonState
|
|
40
|
+
action: PlanAction
|
|
41
|
+
local_kind: EntryKind | None
|
|
42
|
+
remote_kind: EntryKind | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ComparisonPlan:
|
|
47
|
+
direction: Direction
|
|
48
|
+
prune_remote: bool
|
|
49
|
+
entries: tuple[ComparisonEntry, ...]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def differences(self) -> tuple[ComparisonEntry, ...]:
|
|
53
|
+
return tuple(entry for entry in self.entries if entry.action != "unchanged")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def mark_untraversed_directories(
|
|
57
|
+
plan: ComparisonPlan,
|
|
58
|
+
paths: frozenset[str],
|
|
59
|
+
) -> ComparisonPlan:
|
|
60
|
+
"""Make directory boundaries visible without making them executable."""
|
|
61
|
+
retained_ancestors = frozenset(
|
|
62
|
+
parent.as_posix()
|
|
63
|
+
for path in paths
|
|
64
|
+
for parent in PurePosixPath(path).parents
|
|
65
|
+
)
|
|
66
|
+
return replace(
|
|
67
|
+
plan,
|
|
68
|
+
entries=tuple(
|
|
69
|
+
replace(entry, action="untraversed")
|
|
70
|
+
if entry.path in paths and entry.action != "excluded"
|
|
71
|
+
else replace(entry, action="skip")
|
|
72
|
+
if entry.action == "delete-remote"
|
|
73
|
+
and entry.remote_kind == "directory"
|
|
74
|
+
and entry.path in retained_ancestors
|
|
75
|
+
else entry
|
|
76
|
+
for entry in plan.entries
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _comparable_timestamps(
|
|
82
|
+
local: TreeEntry, remote: TreeEntry
|
|
83
|
+
) -> tuple[int, int] | None:
|
|
84
|
+
if (
|
|
85
|
+
local.modified_ns is None
|
|
86
|
+
or remote.modified_ns is None
|
|
87
|
+
or local.timestamp_precision_ns is None
|
|
88
|
+
or remote.timestamp_precision_ns is None
|
|
89
|
+
):
|
|
90
|
+
return None
|
|
91
|
+
precision_ns = max(
|
|
92
|
+
local.timestamp_precision_ns,
|
|
93
|
+
remote.timestamp_precision_ns,
|
|
94
|
+
)
|
|
95
|
+
return (
|
|
96
|
+
local.modified_ns // precision_ns,
|
|
97
|
+
remote.modified_ns // precision_ns,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _files_identical(local: TreeEntry, remote: TreeEntry) -> bool:
|
|
102
|
+
if local.size != remote.size:
|
|
103
|
+
return False
|
|
104
|
+
timestamps = _comparable_timestamps(local, remote)
|
|
105
|
+
return timestamps is not None and timestamps[0] == timestamps[1]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _source_is_newer(
|
|
109
|
+
local: TreeEntry, remote: TreeEntry, direction: Direction
|
|
110
|
+
) -> bool:
|
|
111
|
+
timestamps = _comparable_timestamps(local, remote)
|
|
112
|
+
if timestamps is None:
|
|
113
|
+
return False
|
|
114
|
+
local_time, remote_time = timestamps
|
|
115
|
+
return local_time > remote_time if direction == "push" else remote_time > local_time
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_comparison(
|
|
119
|
+
local: TreeSnapshot,
|
|
120
|
+
remote: TreeSnapshot,
|
|
121
|
+
*,
|
|
122
|
+
direction: Direction = "push",
|
|
123
|
+
prune_remote: bool = False,
|
|
124
|
+
force: bool = False,
|
|
125
|
+
selector: FileSelection | None = None,
|
|
126
|
+
) -> ComparisonPlan:
|
|
127
|
+
local_entries = {entry.path: entry for entry in local.entries}
|
|
128
|
+
remote_entries = {entry.path: entry for entry in remote.entries}
|
|
129
|
+
remote_exclusion_roots = tuple(
|
|
130
|
+
PurePosixPath(entry.path)
|
|
131
|
+
for entry in (*local.entries, *remote.entries)
|
|
132
|
+
if entry.remote_excluded
|
|
133
|
+
)
|
|
134
|
+
comparison: list[ComparisonEntry] = []
|
|
135
|
+
|
|
136
|
+
for path in sorted(local_entries.keys() | remote_entries.keys()):
|
|
137
|
+
candidate_path = PurePosixPath(path)
|
|
138
|
+
if any(root in candidate_path.parents for root in remote_exclusion_roots):
|
|
139
|
+
continue
|
|
140
|
+
local_entry = local_entries.get(path)
|
|
141
|
+
remote_entry = remote_entries.get(path)
|
|
142
|
+
if selector is not None and not selector.matches(path):
|
|
143
|
+
continue
|
|
144
|
+
protected_entry = next(
|
|
145
|
+
(
|
|
146
|
+
entry
|
|
147
|
+
for entry in (remote_entry, local_entry)
|
|
148
|
+
if entry is not None and entry.remote_excluded
|
|
149
|
+
),
|
|
150
|
+
None,
|
|
151
|
+
)
|
|
152
|
+
if protected_entry is not None:
|
|
153
|
+
comparison.append(
|
|
154
|
+
ComparisonEntry(
|
|
155
|
+
path=path,
|
|
156
|
+
state="remote-excluded",
|
|
157
|
+
action="excluded",
|
|
158
|
+
local_kind=local_entry.kind if local_entry else None,
|
|
159
|
+
remote_kind=remote_entry.kind if remote_entry else None,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
continue
|
|
163
|
+
if any(
|
|
164
|
+
entry is not None and entry.excluded
|
|
165
|
+
for entry in (local_entry, remote_entry)
|
|
166
|
+
):
|
|
167
|
+
excluded_directory = any(
|
|
168
|
+
entry is not None
|
|
169
|
+
and entry.excluded
|
|
170
|
+
and entry.kind == "directory"
|
|
171
|
+
for entry in (local_entry, remote_entry)
|
|
172
|
+
)
|
|
173
|
+
if (
|
|
174
|
+
direction == "push"
|
|
175
|
+
and prune_remote
|
|
176
|
+
and remote_entry is not None
|
|
177
|
+
and not excluded_directory
|
|
178
|
+
):
|
|
179
|
+
comparison.append(
|
|
180
|
+
ComparisonEntry(
|
|
181
|
+
path=path,
|
|
182
|
+
state="excluded",
|
|
183
|
+
action="delete-remote",
|
|
184
|
+
local_kind=local_entry.kind if local_entry else None,
|
|
185
|
+
remote_kind=remote_entry.kind,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
continue
|
|
189
|
+
comparison.append(
|
|
190
|
+
ComparisonEntry(
|
|
191
|
+
path=path,
|
|
192
|
+
state="excluded",
|
|
193
|
+
action="excluded",
|
|
194
|
+
local_kind=local_entry.kind if local_entry else None,
|
|
195
|
+
remote_kind=remote_entry.kind if remote_entry else None,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
continue
|
|
199
|
+
if local_entry is None:
|
|
200
|
+
comparison.append(
|
|
201
|
+
ComparisonEntry(
|
|
202
|
+
path=path,
|
|
203
|
+
state="remote-only",
|
|
204
|
+
action="delete-remote" if prune_remote else "skip",
|
|
205
|
+
local_kind=None,
|
|
206
|
+
remote_kind=remote_entry.kind if remote_entry else None,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
continue
|
|
210
|
+
if remote_entry is None:
|
|
211
|
+
if local_entry.kind == "symlink":
|
|
212
|
+
action: PlanAction = "conflict"
|
|
213
|
+
elif direction == "pull":
|
|
214
|
+
action = "skip"
|
|
215
|
+
elif local_entry.kind == "directory":
|
|
216
|
+
action = "create-remote"
|
|
217
|
+
else:
|
|
218
|
+
action = "upload"
|
|
219
|
+
comparison.append(
|
|
220
|
+
ComparisonEntry(
|
|
221
|
+
path=path,
|
|
222
|
+
state="local-only",
|
|
223
|
+
action=action,
|
|
224
|
+
local_kind=local_entry.kind,
|
|
225
|
+
remote_kind=None,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
continue
|
|
229
|
+
if local_entry.kind != remote_entry.kind:
|
|
230
|
+
state: ComparisonState = "type-conflict"
|
|
231
|
+
action = "conflict"
|
|
232
|
+
elif local_entry.kind == "symlink":
|
|
233
|
+
state = "symlink-conflict"
|
|
234
|
+
action = "conflict"
|
|
235
|
+
elif local_entry.kind == "directory" or _files_identical(
|
|
236
|
+
local_entry, remote_entry
|
|
237
|
+
):
|
|
238
|
+
state = "identical"
|
|
239
|
+
action = "unchanged"
|
|
240
|
+
elif force or _source_is_newer(local_entry, remote_entry, direction):
|
|
241
|
+
state = "changed"
|
|
242
|
+
action = "replace-remote" if direction == "push" else "replace-local"
|
|
243
|
+
else:
|
|
244
|
+
state = "destination-newer"
|
|
245
|
+
action = "conflict"
|
|
246
|
+
comparison.append(
|
|
247
|
+
ComparisonEntry(
|
|
248
|
+
path=path,
|
|
249
|
+
state=state,
|
|
250
|
+
action=action,
|
|
251
|
+
local_kind=local_entry.kind,
|
|
252
|
+
remote_kind=remote_entry.kind,
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
protected_paths = tuple(
|
|
257
|
+
PurePosixPath(entry.path)
|
|
258
|
+
for entry in comparison
|
|
259
|
+
if entry.state == "remote-excluded"
|
|
260
|
+
)
|
|
261
|
+
if protected_paths:
|
|
262
|
+
comparison = [
|
|
263
|
+
replace(entry, action="skip")
|
|
264
|
+
if entry.action == "delete-remote"
|
|
265
|
+
and entry.remote_kind == "directory"
|
|
266
|
+
and any(
|
|
267
|
+
PurePosixPath(entry.path) in path.parents
|
|
268
|
+
for path in protected_paths
|
|
269
|
+
)
|
|
270
|
+
else entry
|
|
271
|
+
for entry in comparison
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
if selector is not None and not comparison:
|
|
275
|
+
raise SelectionError(
|
|
276
|
+
f"file selector '{selector.pattern}' matched no paths"
|
|
277
|
+
)
|
|
278
|
+
return ComparisonPlan(direction, prune_remote, tuple(comparison))
|