hlsync 1.16.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/storage.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def write_json_atomic(path: Path, document: dict[str, Any]) -> None:
11
+ """Atomically replace a JSON configuration document."""
12
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
13
+ payload = json.dumps(document, indent=2, sort_keys=True) + "\n"
14
+ descriptor, temporary_name = tempfile.mkstemp(
15
+ dir=path.parent,
16
+ prefix=f".{path.name}.",
17
+ text=True,
18
+ )
19
+ temporary_path = Path(temporary_name)
20
+ try:
21
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
22
+ os.chmod(temporary_path, 0o600)
23
+ handle.write(payload)
24
+ handle.flush()
25
+ os.fsync(handle.fileno())
26
+ temporary_path.replace(path)
27
+ except BaseException:
28
+ temporary_path.unlink(missing_ok=True)
29
+ raise
hlsync/transfer.py ADDED
@@ -0,0 +1,465 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ import tempfile
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path, PurePosixPath
9
+ from typing import Literal
10
+
11
+ from hlsync.comparison import ComparisonEntry, ComparisonPlan
12
+ from hlsync.snapshot import EntryKind, TreeEntry, TreeSnapshot
13
+ from hlsync.transport import PathOperationError, RemoteTransport
14
+
15
+
16
+ class TransferError(RuntimeError):
17
+ """Raised when a transfer plan cannot be executed safely."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class TransferIssue:
22
+ path: str
23
+ status: str
24
+ reason: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TransferOperation:
29
+ action: Literal["add", "update", "delete", "create"]
30
+ path: str
31
+ kind: EntryKind
32
+
33
+
34
+ TransferProgress = Callable[[TransferOperation | TransferIssue], None]
35
+
36
+
37
+ def _record_issue(
38
+ issues: list[TransferIssue],
39
+ issue: TransferIssue,
40
+ progress: TransferProgress | None,
41
+ ) -> None:
42
+ issues.append(issue)
43
+ if progress is not None:
44
+ progress(issue)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class TransferResult:
49
+ plan: ComparisonPlan
50
+ completed_paths: frozenset[str]
51
+ issues: tuple[TransferIssue, ...] = ()
52
+
53
+ @property
54
+ def changed_count(self) -> int:
55
+ return len(self.completed_paths)
56
+
57
+ @property
58
+ def unchanged_file_count(self) -> int:
59
+ return sum(
60
+ entry.action == "unchanged" and entry.local_kind == "file"
61
+ for entry in self.plan.entries
62
+ )
63
+
64
+ @property
65
+ def failed_count(self) -> int:
66
+ return sum(issue.status == "failed" for issue in self.issues)
67
+
68
+ @property
69
+ def skipped_count(self) -> int:
70
+ return sum(issue.status == "skipped" for issue in self.issues)
71
+
72
+ @property
73
+ def succeeded(self) -> bool:
74
+ return not self.issues
75
+
76
+
77
+ def _entry_map(snapshot: TreeSnapshot) -> dict[str, TreeEntry]:
78
+ return {entry.path: entry for entry in snapshot.entries}
79
+
80
+
81
+ def _preflight(plan: ComparisonPlan, local_root: Path, local: TreeSnapshot) -> None:
82
+ conflicts = [entry for entry in plan.entries if entry.action == "conflict"]
83
+ if conflicts:
84
+ message = "transfer has unresolved conflict(s): " + ", ".join(
85
+ entry.path for entry in conflicts
86
+ )
87
+ if any(entry.state == "destination-newer" for entry in conflicts):
88
+ message += (
89
+ "; destination files not older than their source are kept"
90
+ " unless --force is given"
91
+ )
92
+ raise TransferError(message)
93
+ local_entries = _entry_map(local)
94
+ for entry in plan.entries:
95
+ if entry.action not in {"upload", "replace-remote", "replace-local"}:
96
+ continue
97
+ expected = local_entries[entry.path]
98
+ path = local_root / Path(*PurePosixPath(entry.path).parts)
99
+ try:
100
+ current = path.stat(follow_symlinks=False)
101
+ except OSError as error:
102
+ raise TransferError(
103
+ f"could not validate local source '{path}': {error}"
104
+ ) from error
105
+ if not stat.S_ISREG(current.st_mode):
106
+ raise TransferError(f"local source is no longer a file: {path}")
107
+ if (
108
+ current.st_size != expected.size
109
+ or current.st_mtime_ns != expected.modified_ns
110
+ ):
111
+ raise TransferError(f"local source changed after snapshot: {path}")
112
+
113
+
114
+ def _remote_directories_to_create(
115
+ plan: ComparisonPlan,
116
+ remote: TreeSnapshot,
117
+ ) -> tuple[str, ...]:
118
+ existing = {
119
+ entry.path for entry in remote.entries if entry.kind == "directory"
120
+ }
121
+ required = {
122
+ entry.path
123
+ for entry in plan.entries
124
+ if entry.action == "create-remote"
125
+ }
126
+ for entry in plan.entries:
127
+ if entry.action not in {"upload", "replace-remote"}:
128
+ continue
129
+ parent = PurePosixPath(entry.path).parent
130
+ while parent.parts:
131
+ if parent.as_posix() not in existing:
132
+ required.add(parent.as_posix())
133
+ parent = parent.parent
134
+ return tuple(
135
+ sorted(
136
+ required,
137
+ key=lambda path: (len(PurePosixPath(path).parts), path),
138
+ )
139
+ )
140
+
141
+
142
+ def planned_transfer_operations(
143
+ plan: ComparisonPlan,
144
+ remote: TreeSnapshot,
145
+ ) -> tuple[TransferOperation, ...]:
146
+ """Return successful-path operations in their execution order."""
147
+ if plan.direction == "pull":
148
+ return tuple(
149
+ TransferOperation("update", entry.path, "file")
150
+ for entry in plan.entries
151
+ if entry.action == "replace-local"
152
+ )
153
+ creations = tuple(
154
+ TransferOperation("create", path, "directory")
155
+ for path in _remote_directories_to_create(plan, remote)
156
+ )
157
+ uploads = tuple(
158
+ TransferOperation(
159
+ "add" if entry.action == "upload" else "update",
160
+ entry.path,
161
+ "file",
162
+ )
163
+ for entry in plan.entries
164
+ if entry.action in {"upload", "replace-remote"}
165
+ )
166
+ deletions = tuple(
167
+ entry for entry in plan.entries if entry.action == "delete-remote"
168
+ )
169
+ files = sorted(
170
+ (entry for entry in deletions if entry.remote_kind != "directory"),
171
+ key=lambda entry: entry.path,
172
+ )
173
+ directories = sorted(
174
+ (entry for entry in deletions if entry.remote_kind == "directory"),
175
+ key=lambda entry: (-len(PurePosixPath(entry.path).parts), entry.path),
176
+ )
177
+ deletes = tuple(
178
+ TransferOperation("delete", entry.path, entry.remote_kind or "file")
179
+ for entry in (*files, *directories)
180
+ )
181
+ return creations + uploads + deletes
182
+
183
+
184
+ def _push_files(
185
+ plan: ComparisonPlan,
186
+ local_root: Path,
187
+ local: TreeSnapshot,
188
+ transport: RemoteTransport,
189
+ progress: TransferProgress | None,
190
+ operations: tuple[TransferOperation, ...],
191
+ dry_run: bool,
192
+ byte_progress: Callable[[int], None] | None,
193
+ ) -> tuple[set[str], list[TransferIssue]]:
194
+ local_entries = _entry_map(local)
195
+ completed: set[str] = set()
196
+ issues: list[TransferIssue] = []
197
+ unavailable_directories: set[str] = set()
198
+ planned_creations = {
199
+ entry.path
200
+ for entry in plan.entries
201
+ if entry.action == "create-remote"
202
+ }
203
+
204
+ def unavailable_parent(path: str) -> str | None:
205
+ candidate = PurePosixPath(path)
206
+ return next(
207
+ (
208
+ directory
209
+ for directory in sorted(unavailable_directories)
210
+ if PurePosixPath(directory) == candidate
211
+ or PurePosixPath(directory) in candidate.parents
212
+ ),
213
+ None,
214
+ )
215
+
216
+ for operation in (item for item in operations if item.action == "create"):
217
+ directory = operation.path
218
+ blocked_by = unavailable_parent(directory)
219
+ if blocked_by is not None:
220
+ unavailable_directories.add(directory)
221
+ if directory in planned_creations:
222
+ _record_issue(
223
+ issues,
224
+ TransferIssue(
225
+ directory,
226
+ "skipped",
227
+ f"parent directory '{blocked_by}' could not be created",
228
+ ),
229
+ progress,
230
+ )
231
+ continue
232
+ try:
233
+ if not dry_run:
234
+ transport.make_directory(directory)
235
+ except PathOperationError as error:
236
+ unavailable_directories.add(directory)
237
+ _record_issue(
238
+ issues, TransferIssue(directory, "failed", str(error)), progress
239
+ )
240
+ continue
241
+ if progress is not None:
242
+ progress(operation)
243
+ if directory in planned_creations:
244
+ completed.add(directory)
245
+ for operation in (
246
+ item for item in operations if item.action in {"add", "update"}
247
+ ):
248
+ blocked_by = unavailable_parent(operation.path)
249
+ if blocked_by is not None:
250
+ _record_issue(
251
+ issues,
252
+ TransferIssue(
253
+ operation.path,
254
+ "skipped",
255
+ f"parent directory '{blocked_by}' is unavailable",
256
+ ),
257
+ progress,
258
+ )
259
+ continue
260
+ metadata = local_entries[operation.path]
261
+ if metadata.size is None or metadata.modified_ns is None:
262
+ raise TransferError(
263
+ f"local file metadata is incomplete: {operation.path}"
264
+ )
265
+ path = local_root / Path(*PurePosixPath(operation.path).parts)
266
+ try:
267
+ if not dry_run:
268
+ with path.open("rb") as source:
269
+ transport.upload_file(
270
+ source,
271
+ operation.path,
272
+ size=metadata.size,
273
+ modified_ns=metadata.modified_ns,
274
+ replace=operation.action == "update",
275
+ byte_progress=byte_progress,
276
+ )
277
+ except PathOperationError as error:
278
+ _record_issue(
279
+ issues, TransferIssue(operation.path, "failed", str(error)), progress
280
+ )
281
+ continue
282
+ except OSError as error:
283
+ _record_issue(
284
+ issues,
285
+ TransferIssue(
286
+ operation.path,
287
+ "failed",
288
+ f"could not read local file '{path}': {error}",
289
+ ),
290
+ progress,
291
+ )
292
+ continue
293
+ completed.add(operation.path)
294
+ if progress is not None:
295
+ progress(operation)
296
+ return completed, issues
297
+
298
+
299
+ def _replace_local_file(
300
+ root: Path,
301
+ comparison: ComparisonEntry,
302
+ local_entry: TreeEntry,
303
+ remote_entry: TreeEntry,
304
+ transport: RemoteTransport,
305
+ ) -> None:
306
+ destination = root / Path(*PurePosixPath(comparison.path).parts)
307
+ try:
308
+ mode = stat.S_IMODE(destination.stat(follow_symlinks=False).st_mode)
309
+ except OSError as error:
310
+ raise TransferError(
311
+ f"could not inspect local destination '{destination}': {error}"
312
+ ) from error
313
+ descriptor, temporary_name = tempfile.mkstemp(
314
+ dir=destination.parent,
315
+ prefix=f".{destination.name}.hlsync-pull-",
316
+ )
317
+ temporary = Path(temporary_name)
318
+ try:
319
+ with os.fdopen(descriptor, "wb") as target:
320
+ transport.download_file(comparison.path, target)
321
+ target.flush()
322
+ os.fsync(target.fileno())
323
+ actual_size = temporary.stat().st_size
324
+ if actual_size != remote_entry.size:
325
+ raise TransferError(
326
+ f"download size mismatch for '{comparison.path}': expected "
327
+ f"{remote_entry.size}, got {actual_size}"
328
+ )
329
+ os.chmod(temporary, mode)
330
+ if remote_entry.modified_ns is None:
331
+ raise TransferError(
332
+ f"remote file timestamp is missing: {comparison.path}"
333
+ )
334
+ os.utime(
335
+ temporary,
336
+ ns=(remote_entry.modified_ns, remote_entry.modified_ns),
337
+ )
338
+ current = destination.stat(follow_symlinks=False)
339
+ if (
340
+ not stat.S_ISREG(current.st_mode)
341
+ or current.st_size != local_entry.size
342
+ or current.st_mtime_ns != local_entry.modified_ns
343
+ ):
344
+ raise TransferError(
345
+ f"local destination changed during download: {destination}"
346
+ )
347
+ temporary.replace(destination)
348
+ except BaseException:
349
+ temporary.unlink(missing_ok=True)
350
+ raise
351
+
352
+
353
+ def _pull_files(
354
+ plan: ComparisonPlan,
355
+ local_root: Path,
356
+ local: TreeSnapshot,
357
+ remote: TreeSnapshot,
358
+ transport: RemoteTransport,
359
+ progress: TransferProgress | None,
360
+ operations: tuple[TransferOperation, ...],
361
+ ) -> set[str]:
362
+ completed: set[str] = set()
363
+ local_entries = _entry_map(local)
364
+ remote_entries = _entry_map(remote)
365
+ entries = {entry.path: entry for entry in plan.entries}
366
+ for operation in operations:
367
+ entry = entries[operation.path]
368
+ _replace_local_file(
369
+ local_root,
370
+ entry,
371
+ local_entries[operation.path],
372
+ remote_entries[operation.path],
373
+ transport,
374
+ )
375
+ completed.add(operation.path)
376
+ if progress is not None:
377
+ progress(operation)
378
+ return completed
379
+
380
+
381
+ def _delete_remote(
382
+ transport: RemoteTransport,
383
+ progress: TransferProgress | None,
384
+ operations: tuple[TransferOperation, ...],
385
+ dry_run: bool,
386
+ ) -> tuple[set[str], list[TransferIssue]]:
387
+ completed: set[str] = set()
388
+ issues: list[TransferIssue] = []
389
+ for operation in (item for item in operations if item.action == "delete"):
390
+ try:
391
+ if not dry_run:
392
+ transport.delete_path(
393
+ operation.path,
394
+ is_directory=operation.kind == "directory",
395
+ )
396
+ except PathOperationError as error:
397
+ _record_issue(
398
+ issues, TransferIssue(operation.path, "failed", str(error)), progress
399
+ )
400
+ continue
401
+ completed.add(operation.path)
402
+ if progress is not None:
403
+ progress(operation)
404
+ return completed, issues
405
+
406
+
407
+ def execute_transfer(
408
+ plan: ComparisonPlan,
409
+ *,
410
+ local_root: Path,
411
+ local: TreeSnapshot,
412
+ remote: TreeSnapshot,
413
+ transport: RemoteTransport,
414
+ progress: TransferProgress | None = None,
415
+ dry_run: bool = False,
416
+ byte_progress: Callable[[int], None] | None = None,
417
+ ) -> TransferResult:
418
+ if dry_run and plan.direction != "push":
419
+ raise TransferError("dry run is supported only for push")
420
+ _preflight(plan, local_root, local)
421
+ operations = planned_transfer_operations(plan, remote)
422
+ if plan.direction == "push":
423
+ completed, issues = _push_files(
424
+ plan,
425
+ local_root,
426
+ local,
427
+ transport,
428
+ progress,
429
+ operations,
430
+ dry_run,
431
+ byte_progress,
432
+ )
433
+ else:
434
+ completed = _pull_files(
435
+ plan,
436
+ local_root,
437
+ local,
438
+ remote,
439
+ transport,
440
+ progress,
441
+ operations,
442
+ )
443
+ issues = []
444
+ if issues:
445
+ for entry in plan.entries:
446
+ if entry.action == "delete-remote":
447
+ _record_issue(
448
+ issues,
449
+ TransferIssue(
450
+ entry.path,
451
+ "skipped",
452
+ "remote pruning was suppressed after another transfer failed",
453
+ ),
454
+ progress,
455
+ )
456
+ else:
457
+ deleted, deletion_issues = _delete_remote(
458
+ transport,
459
+ progress,
460
+ operations,
461
+ dry_run,
462
+ )
463
+ completed.update(deleted)
464
+ issues.extend(deletion_issues)
465
+ return TransferResult(plan, frozenset(completed), tuple(issues))