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/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))