pathapi 0.1.0__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.
Files changed (104) hide show
  1. pathapi/AGENTS.md +2355 -0
  2. pathapi/README.md +205 -0
  3. pathapi/__init__.py +62 -0
  4. pathapi/_commit.py +116 -0
  5. pathapi/_discard.py +92 -0
  6. pathapi/_errorpolicy.py +106 -0
  7. pathapi/_filestat.py +194 -0
  8. pathapi/_lru.py +138 -0
  9. pathapi/_names.py +70 -0
  10. pathapi/_permissions.py +102 -0
  11. pathapi/_stubs.py +76 -0
  12. pathapi/_tree.py +136 -0
  13. pathapi/archive/__init__.py +44 -0
  14. pathapi/archive/_backend.py +393 -0
  15. pathapi/archive/_names.py +63 -0
  16. pathapi/archive/_pack.py +394 -0
  17. pathapi/archive/_path.py +352 -0
  18. pathapi/archive/_snapshot.py +269 -0
  19. pathapi/archive/_tar.py +126 -0
  20. pathapi/archive/_write.py +359 -0
  21. pathapi/archive/_zip.py +563 -0
  22. pathapi/archive/_zipentries.py +272 -0
  23. pathapi/checksum.py +61 -0
  24. pathapi/exceptions.py +22 -0
  25. pathapi/fspath.py +513 -0
  26. pathapi/ftp/__init__.py +27 -0
  27. pathapi/ftp/_backend.py +418 -0
  28. pathapi/ftp/_facts.py +117 -0
  29. pathapi/ftp/_path.py +582 -0
  30. pathapi/glob/__init__.py +27 -0
  31. pathapi/glob/_entries.py +103 -0
  32. pathapi/glob/_patterns.py +164 -0
  33. pathapi/glob/_recursion.py +180 -0
  34. pathapi/glob/_selection.py +303 -0
  35. pathapi/http/__init__.py +84 -0
  36. pathapi/http/_body.py +156 -0
  37. pathapi/http/_builtin.py +553 -0
  38. pathapi/http/_client.py +211 -0
  39. pathapi/http/_errors.py +79 -0
  40. pathapi/http/_follow.py +230 -0
  41. pathapi/http/_headers.py +233 -0
  42. pathapi/http/_pool.py +169 -0
  43. pathapi/http/_proxies.py +142 -0
  44. pathapi/http/_response.py +255 -0
  45. pathapi/http/_settings.py +12 -0
  46. pathapi/http/_status.py +68 -0
  47. pathapi/http/_transports.py +173 -0
  48. pathapi/http/testing/__init__.py +80 -0
  49. pathapi/http/testing/__main__.py +10 -0
  50. pathapi/http/testing/suite/conftest.py +98 -0
  51. pathapi/http/testing/suite/test_client_default.py +285 -0
  52. pathapi/http/testing/suite/test_client_errors.py +181 -0
  53. pathapi/http/testing/suite/test_client_peer_text.py +86 -0
  54. pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
  55. pathapi/http/testing/suite/test_client_redirects.py +231 -0
  56. pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
  57. pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
  58. pathapi/http/testing/suite/test_transport_body.py +302 -0
  59. pathapi/http/testing/suite/test_transport_send.py +662 -0
  60. pathapi/http/testing/suite/transport_fakes.py +133 -0
  61. pathapi/http/testing/suite/transport_guard.py +174 -0
  62. pathapi/http/testing/suite/transport_wire.py +511 -0
  63. pathapi/mempath.py +438 -0
  64. pathapi/path/__init__.py +19 -0
  65. pathapi/path/_info.py +10 -0
  66. pathapi/path/_path.py +198 -0
  67. pathapi/path/_pure.py +522 -0
  68. pathapi/path/_relation.py +10 -0
  69. pathapi/path/_transfer.py +10 -0
  70. pathapi/protocols/__init__.py +35 -0
  71. pathapi/protocols/_bundles.py +22 -0
  72. pathapi/protocols/_errors.py +12 -0
  73. pathapi/protocols/_info.py +65 -0
  74. pathapi/protocols/_relation.py +191 -0
  75. pathapi/protocols/_removal.py +69 -0
  76. pathapi/protocols/_support.py +122 -0
  77. pathapi/protocols/_transfer.py +100 -0
  78. pathapi/protocols/checksum.py +80 -0
  79. pathapi/protocols/fs.py +429 -0
  80. pathapi/protocols/io.py +224 -0
  81. pathapi/protocols/links.py +135 -0
  82. pathapi/protocols/pure.py +31 -0
  83. pathapi/protocols/resolve.py +97 -0
  84. pathapi/protocols/transfer.py +656 -0
  85. pathapi/protocols/tree.py +492 -0
  86. pathapi/providers.py +48 -0
  87. pathapi/py.typed +1 -0
  88. pathapi/sync/__init__.py +8 -0
  89. pathapi/sync/_compare.py +157 -0
  90. pathapi/sync/_events.py +97 -0
  91. pathapi/sync/_links.py +93 -0
  92. pathapi/sync/_offer.py +33 -0
  93. pathapi/sync/_paths.py +50 -0
  94. pathapi/sync/_replace.py +98 -0
  95. pathapi/sync/_syncer.py +989 -0
  96. pathapi/testing/__init__.py +96 -0
  97. pathapi/testing/_contract.py +522 -0
  98. pathapi/testing/_fixtures.py +83 -0
  99. pathapi/testing/_read.py +279 -0
  100. pathapi-0.1.0.dist-info/METADATA +242 -0
  101. pathapi-0.1.0.dist-info/RECORD +104 -0
  102. pathapi-0.1.0.dist-info/WHEEL +4 -0
  103. pathapi-0.1.0.dist-info/entry_points.txt +2 -0
  104. pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,989 @@
1
+ """`PathSyncer`: make one tree of paths match another."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib as _contextlib
6
+ import errno as _errno
7
+ import logging as _logging
8
+ import time as _time
9
+ import typing as _ty
10
+
11
+ from .. import _errorpolicy, _names
12
+ from ..path import Path
13
+ from ..protocols import Copy, Move, Utime
14
+ from ..protocols._bundles import Readable
15
+ from ..protocols import supports as _supports
16
+ from ..protocols._removal import _listing, _write_batch_of
17
+ from .._filestat import FileStat
18
+ from ..glob import _entries
19
+ from ._compare import (
20
+ _default_checksum,
21
+ _default_checksums_match,
22
+ _is_local,
23
+ _quick_check_in_sync,
24
+ )
25
+ from ._events import PathAndStat, SyncEvent
26
+ from ._links import (
27
+ _as_entry,
28
+ _link_is_directory,
29
+ _link_text,
30
+ _same_link,
31
+ _supports_symlinks,
32
+ _unresolved_link,
33
+ )
34
+ from ._offer import _offer_error
35
+ from ._paths import _is_junction, _is_mount, _paths_overlap
36
+ from ._replace import (
37
+ _LIVE_TEMPS,
38
+ _ONLY_COPY_ATTR,
39
+ _STALE_AFTER,
40
+ _is_leftover,
41
+ _only_copy,
42
+ _replace,
43
+ _temp_sibling,
44
+ )
45
+
46
+ _logger = _logging.getLogger("pathapi.sync")
47
+
48
+
49
+ class _OnPathSyncerError(_ty.Protocol):
50
+ def __call__(
51
+ self,
52
+ error: Exception,
53
+ source: PathAndStat,
54
+ target: PathAndStat,
55
+ event: SyncEvent,
56
+ ) -> bool: ...
57
+
58
+
59
+ class PathSyncer(object):
60
+ """One-way checksum-driven tree sync: copies/creates in `target`
61
+ whatever differs from `source` (by `checksum`), optionally removing
62
+ files in `target` that are missing from `source`. Works across any two
63
+ `Path` implementations (e.g. `MemPath` -> `LocalPath`, or between two
64
+ remote backends) -- see `sync()`.
65
+
66
+ The default `checksum` policy prefers each side's backend-native
67
+ digest (`protocols.checksum.NativeChecksum.checksum()`, e.g.
68
+ an SFTP path's `check-file-handle` support) over streaming the file
69
+ through `open("rb")`, but only when BOTH sides can produce a digest
70
+ under the same algorithm -- native or streamed. If either side can't
71
+ (missing the protocol, or it raises `NotImplementedError` for the
72
+ requested algorithm), both sides fall back to streaming rather than
73
+ comparing a native digest to a streamed one. A custom `checksum`
74
+ callable disables this native-preferring behavior entirely (it is
75
+ called exactly as before, once per side, compared with `==`).
76
+
77
+ `quick_check=True` (the default) adds a cheap metadata-only
78
+ pre-check -- the classic rsync "quick check" heuristic -- for any pair
79
+ where at least one side is non-local (`is_local()`; a side without
80
+ an `is_local()` method at all, e.g. plain `LocalPath`/`MemPath`, is
81
+ treated as local): if `st_size` AND `st_mtime` already match (from the
82
+ listing/stat metadata `PathAndStat` already carries -- no extra round
83
+ trip), the pair is treated as in sync WITHOUT calling `checksum` at
84
+ all, native or streamed. Times match at the resolution of the coarser
85
+ side: in whole seconds when either has no fractional part (SFTP, FTP and
86
+ the object stores report seconds), to a microsecond otherwise. A mismatch
87
+ on either falls through to a real
88
+ checksum comparison rather than being treated as "changed" -- mtime can
89
+ be unreliable across backends/clock skew, so a false "needs copy" from
90
+ a mismatch is merely wasteful, while a false "in sync" would be a
91
+ correctness regression. An `st_mtime` of 0 on either side means
92
+ "unknown" and never matches. Local-to-local pairs always skip this
93
+ pre-check (local reads are already cheap, and
94
+ `copy(preserve_metadata=True)` doesn't
95
+ guarantee mtime propagation on every path, see `docs/divergences.md`).
96
+ Set `quick_check=False` to disable the pre-check entirely and always
97
+ checksum.
98
+
99
+ `preserve_mtime=True` (the default) gives each file the sync copies the
100
+ modification time the source reported, where the target's class can set one
101
+ (`LocalPath`, `MemPath`, and `FTPPath` on a server that lists `MFMT`;
102
+ a class of another distribution says so through `_utime()`), so that the next run's quick check can match. A source
103
+ that reports no time (0), and a target that cannot set one, leave the
104
+ time the copy gave the file; neither is an error. Directories and links are
105
+ not touched. `preserve_mtime=False` leaves every copy with the time it was
106
+ written at.
107
+
108
+ `follow_symlinks` (default `True`) controls whether a symlink source is
109
+ resolved during traversal (content synced as if it weren't a link) or
110
+ reported as a symlink (`is_symlink()` true). When it's `False` and a
111
+ symlink source is reached, `symlink_mode` decides what happens:
112
+ `"preserve"` (default) creates a matching symlink on `target` with the
113
+ same raw, unresolved target string `readlink()` returned (dangling
114
+ links and relative targets included -- never resolved/validated);
115
+ `"reject"` raises `NotImplementedError` instead. If `target` can't
116
+ create symlinks at all
117
+ (most backends -- `LocalPath` and the SFTP backend of another distribution
118
+ implement `symlink_to()`), `"preserve"` mode raises `NotImplementedError` too,
119
+ through the same `ignore_error`/`hook()` machinery as every other
120
+ branch -- decided before an existing target entry is touched, and by a
121
+ dry run as by a real one. Replacing
122
+ an existing entry creates the new link under a temporary sibling name
123
+ first, so a runtime refusal also leaves the entry in place. A target
124
+ link that already has the same raw target string (and, on a Windows
125
+ target, the same file/directory link kind) is left alone. A directory
126
+ link is created as one (`target_is_directory`), which Windows requires.
127
+ A symlink or a Windows junction found inside the target, below its
128
+ root, is replaced by a real directory and never listed, written or
129
+ deleted through. A mount point there (a mounted volume, a bind mount) is
130
+ part of the tree and is synced into, as `rsync` does. With
131
+ `follow_mounts=False` it is an error for that entry instead:
132
+ `OSError(EXDEV)` naming it, offered to `ignore_error` with event
133
+ `SyncEvent.TypeMismatch`, and nothing behind it is listed, written or
134
+ removed, in a dry run as in a real one. The root target is used as given
135
+ either way.
136
+
137
+ `remove_missing=False` never deletes a non-empty target directory: when
138
+ the source entry became a file or a symlink, replacing that directory
139
+ raises `IsADirectoryError` through `ignore_error` (event
140
+ `SyncEvent.TypeMismatch`) and the directory is left untouched. With
141
+ `remove_missing=True`, or for an empty directory, it is replaced.
142
+
143
+ A changed file is written to a temporary sibling and renamed over the
144
+ existing target where the target backend implements `rename()`, so a
145
+ failed or interrupted transfer keeps the previous version. A backend
146
+ without `rename()` is overwritten in place, with the source opened
147
+ before the target is truncated. A source entry that is not a regular
148
+ file, directory or symlink (FIFO, socket, device) is skipped with a
149
+ `SyncEvent.Skipped` event and its target left untouched.
150
+
151
+ The temporary sibling is `.NAME.<12 hex digits>.pathapi-tmp`, NAME
152
+ cut so that the whole stays within 255 bytes. A backend whose `rename()`
153
+ refuses an existing target has the old file removed first; if the second
154
+ rename then fails, the new version stays under its temporary name and
155
+ the error names it. A name of exactly that form is the library's own: it
156
+ is never a sync source and never removed as "missing". A stale one is
157
+ removed from a target directory when a changed file or link in it is
158
+ next written (and by `remove_missing=True`): stale means no sync of this
159
+ process is writing it and its modification time is known and over a day
160
+ old. A younger one, or one whose age the store does not report, stays.
161
+
162
+ Errors: `ignore_error` is consulted once per error, with the
163
+ `PathAndStat` entries that failed and the event that failed
164
+ (`SyncEvent.Compare` for a failing quick check or checksum). A name the
165
+ source directory lists is never "missing": a link whose target cannot be
166
+ resolved (dangling, a loop, an unmounted volume) is an error for that
167
+ entry (event `SyncEvent.SyncStart`), and the same-named target entry is
168
+ left as it is, whatever `remove_missing` is. An error it declines propagates
169
+ without being offered again by enclosing directories. A dry run takes
170
+ the same decisions as a real run (a directory that would be created is
171
+ treated as empty) without changing anything. Every error the policy
172
+ tolerates is logged at WARNING (with the traceback) on the
173
+ `pathapi.sync` logger and reported to `hook` as `SyncEvent.Error`.
174
+
175
+ `hook` receives `PathAndStat` entries for every event, and the `dry_run`
176
+ value of the call for every event, including the structural ones
177
+ (`SyncStart`, `CheckTargetChild(ren)`, `SyncChild(ren)`).
178
+ """
179
+
180
+ __slots__ = (
181
+ "checksum",
182
+ "_hook",
183
+ "remove_missing",
184
+ "follow_symlinks",
185
+ "symlink_mode",
186
+ "ignore_error",
187
+ "quick_check",
188
+ "preserve_mtime",
189
+ "follow_mounts",
190
+ )
191
+ EVENT_LOG_FORMAT = "[%s] Source:%s Target:%s DryRun:%s"
192
+
193
+ def __init__(
194
+ self,
195
+ checksum: _ty.Callable[[PathAndStat], _ty.Any] | None = None,
196
+ /,
197
+ remove_missing: bool = False,
198
+ follow_symlinks: bool = True,
199
+ symlink_mode: '_ty.Literal["preserve", "reject"]' = "preserve",
200
+ hook: _ty.Callable[[PathAndStat, PathAndStat, SyncEvent, bool], None] = None,
201
+ ignore_error: _OnPathSyncerError | bool = False,
202
+ quick_check: bool = True,
203
+ preserve_mtime: bool = True,
204
+ follow_mounts: bool = True,
205
+ ) -> None:
206
+ # `None` resolves to `_default_checksum`, which `sync()` recognizes (via `is`) to
207
+ # decide native versus streaming across BOTH sides at once. A caller-supplied
208
+ # callable is used as-is: `checksum(target) == checksum(source)`.
209
+ if checksum is None:
210
+ checksum = _default_checksum
211
+ self.checksum = checksum
212
+ self.remove_missing = remove_missing
213
+ self._hook = hook
214
+ self.follow_symlinks = follow_symlinks
215
+ if symlink_mode not in ("preserve", "reject"):
216
+ raise ValueError(
217
+ f"symlink_mode must be 'preserve' or 'reject', got {symlink_mode!r}"
218
+ )
219
+ self.symlink_mode = symlink_mode
220
+ self.ignore_error = _ty.cast(
221
+ _OnPathSyncerError, _errorpolicy.as_error_handler(ignore_error)
222
+ )
223
+ self.quick_check = quick_check
224
+ self.preserve_mtime = preserve_mtime
225
+ self.follow_mounts = follow_mounts
226
+
227
+ def log(self, msg: str, *args: object):
228
+ # Overridable hook: a subclass routes sync progress elsewhere (the class has
229
+ # `__slots__`, so an instance cannot be assigned one). `*args` go to the logger
230
+ # lazily, so formatting is skipped unless something listens at INFO.
231
+ _logger.info(msg, *args)
232
+
233
+ def hook(
234
+ self,
235
+ source: PathAndStat,
236
+ target: PathAndStat,
237
+ event: SyncEvent,
238
+ dry_run: bool,
239
+ do: _ty.Callable[[], None] = None,
240
+ ignore_error: _OnPathSyncerError = None,
241
+ *,
242
+ always_run: bool = False,
243
+ ):
244
+ # `ignore_error` passes a per-call policy down; None keeps the instance's.
245
+ # `always_run` runs `do` in a dry run too (the traversal steps, which only read
246
+ # and recurse) while still reporting `dry_run`.
247
+ if ignore_error is None:
248
+ ignore_error = self.ignore_error
249
+ if do and (always_run or not dry_run):
250
+ try:
251
+ do()
252
+ except Exception as e:
253
+ if self._tolerate(ignore_error, e, source, target, event, dry_run):
254
+ return e
255
+ raise
256
+ if self._hook:
257
+ self._hook(source, target, event, dry_run)
258
+ self.log(self.EVENT_LOG_FORMAT, event, source, target, dry_run)
259
+
260
+ def _copy_mtime(self, source: PathAndStat, target: Path) -> None:
261
+ """Give the file just copied to `target` the modification time the
262
+ source reported, where `target`'s class can set one. Not an error of
263
+ the sync when it cannot: the next run compares the content."""
264
+ stat = source.stat
265
+ mtime = getattr(stat, "st_mtime", 0)
266
+ utime = getattr(target, "_utime", None)
267
+ if not (
268
+ self.preserve_mtime
269
+ and utime is not None
270
+ and mtime
271
+ and getattr(stat, "size_known", True)
272
+ and _supports(target, Utime)
273
+ ):
274
+ return
275
+ try:
276
+ utime(mtime=mtime)
277
+ except NotImplementedError:
278
+ pass
279
+ except Exception as error:
280
+ _logger.debug(
281
+ "could not set the modification time of %s: %s", target, error
282
+ )
283
+
284
+ def _tolerate(
285
+ self,
286
+ policy: _OnPathSyncerError,
287
+ error: Exception,
288
+ source,
289
+ target,
290
+ event: SyncEvent,
291
+ dry_run: bool,
292
+ ) -> bool:
293
+ """Offer `error` to `policy` once (see `_offer_error`). A tolerated
294
+ error is logged at WARNING and reported to the hook as
295
+ `SyncEvent.Error`, so `ignore_error=True` never loses it silently."""
296
+ source, target = _as_entry(source), _as_entry(target)
297
+ if not _offer_error(policy, error, source, target, event):
298
+ return False
299
+ _logger.warning(
300
+ "[%s] ignored %s: %s Source:%s Target:%s",
301
+ event,
302
+ type(error).__name__,
303
+ error,
304
+ source,
305
+ target,
306
+ exc_info=(type(error), error, error.__traceback__),
307
+ )
308
+ if self._hook:
309
+ self._hook(source, target, SyncEvent.Error, dry_run)
310
+ self.log(self.EVENT_LOG_FORMAT, SyncEvent.Error, source, target, dry_run)
311
+ return True
312
+
313
+ def _keeps_directory(
314
+ self, source, target, dry_run: bool, policy: _OnPathSyncerError
315
+ ) -> bool:
316
+ """True when a non-directory source must not replace the target:
317
+ the target is a real, non-empty directory and `remove_missing` is
318
+ False. The refusal is an `IsADirectoryError` offered to `policy`
319
+ (raised if declined); the directory is never touched."""
320
+ if self.remove_missing or target.is_symlink() or not target.is_dir():
321
+ return False
322
+ try:
323
+ if next(iter(target.path.iterdir()), None) is None:
324
+ return False
325
+ raise IsADirectoryError(
326
+ _errno.EISDIR,
327
+ "refusing to replace a non-empty directory with a "
328
+ f"{'symlink' if source.is_symlink() else 'file'} "
329
+ "(remove_missing is False)",
330
+ str(target.path),
331
+ )
332
+ except Exception as error:
333
+ if self._tolerate(
334
+ policy, error, source, target, SyncEvent.TypeMismatch, dry_run
335
+ ):
336
+ return True
337
+ raise
338
+
339
+ def _children(
340
+ self,
341
+ entry: PathAndStat,
342
+ windows: bool = False,
343
+ listing: "list | None" = None,
344
+ follow: "bool | None" = None,
345
+ ) -> "list[tuple[str, PathAndStat]]":
346
+ """`(listed name, child)` for each entry of the directory `entry`,
347
+ from `listing` when the caller has already taken it. The child is
348
+ built from the name as listed, described by a stat that follows
349
+ links when `follow` (default: `follow_symlinks`) says so; one that is
350
+ not a safe child name for a destination read with `windows` rules
351
+ gets no stat, and the caller refuses it."""
352
+ if follow is None:
353
+ follow = self.follow_symlinks
354
+ children: "list[tuple[str, PathAndStat]]" = []
355
+ for name, stat in _listing(entry.path) if listing is None else listing:
356
+ child = _entries._child(entry.path, name)
357
+ if not _names.is_safe_child_name(name, windows=windows):
358
+ children.append((name, PathAndStat.from_stat(child, None)))
359
+ elif _is_leftover(name):
360
+ # The library's own temporary file, not an entry of the
361
+ # tree: never copied, never a reason to remove anything.
362
+ # `_sweep()` removes the stale ones.
363
+ continue
364
+ elif not follow:
365
+ # `None` means "stat unknown" (FTP's NLST fallback), not "missing": ask the path
366
+ # before remove_missing can treat the entry as gone. So does a stat that knows
367
+ # the type and not the size (GitLab blobs, an HTTP index without sizes).
368
+ if stat is None or not getattr(stat, "size_known", True):
369
+ stat = FileStat.from_path(child, follow_symlink=False)
370
+ children.append((name, PathAndStat.from_stat(child, stat)))
371
+ else:
372
+ children.append((name, self._followed(stat, child)))
373
+ return children
374
+
375
+ def _sweep(
376
+ self,
377
+ source_dir: Path,
378
+ target_dir: Path,
379
+ dry_run: bool,
380
+ policy: _OnPathSyncerError,
381
+ swept: "set[str]",
382
+ listings: "dict[str, list]",
383
+ ) -> None:
384
+ """Remove the stale temporary files of earlier syncs from
385
+ `target_dir`, once per call of `sync()`. Only a file whose name is
386
+ the library's own pattern, that no sync of this process is writing
387
+ and that has not been modified for `_STALE_AFTER` seconds goes: a
388
+ younger one may belong to a transfer running now, in another
389
+ process, and a store that reports no modification time never
390
+ qualifies. Best effort: a listing or removal that fails is logged,
391
+ not raised."""
392
+ if str(target_dir) in swept:
393
+ return
394
+ swept.add(str(target_dir))
395
+ # The directory's own `_sync` listed it already when it is a
396
+ # target directory being synced; a file's parent is listed here.
397
+ listing = listings.get(str(target_dir))
398
+ if listing is None:
399
+ try:
400
+ listing = list(_listing(target_dir))
401
+ except Exception as error:
402
+ _logger.warning(
403
+ "could not list %s for stale temporary files: %s",
404
+ target_dir,
405
+ error,
406
+ )
407
+ return
408
+ for name, stat in listing:
409
+ if not (_is_leftover(name) and _names.is_safe_child_name(name)):
410
+ continue
411
+ child = _entries._child(target_dir, name)
412
+ if stat is None:
413
+ try:
414
+ stat = FileStat.from_path(child, follow_symlink=False)
415
+ except OSError:
416
+ continue
417
+ if (
418
+ stat is None
419
+ or stat.is_dir()
420
+ or str(child) in _LIVE_TEMPS
421
+ or not stat.st_mtime
422
+ or _time.time() - stat.st_mtime <= _STALE_AFTER
423
+ ):
424
+ continue
425
+ if not dry_run:
426
+ try:
427
+ child.unlink()
428
+ except Exception as error:
429
+ _logger.warning("could not remove stale %s: %s", child, error)
430
+ continue
431
+ self.hook(
432
+ PathAndStat.from_stat(_entries._child(source_dir, name), None),
433
+ PathAndStat.from_stat(child, stat),
434
+ SyncEvent.RemovedMissing,
435
+ dry_run,
436
+ None,
437
+ policy,
438
+ )
439
+
440
+ def _followed(self, listed: "FileStat | None", child: Path) -> PathAndStat:
441
+ """`child` described by the stat that following links asks for. The
442
+ listing's own stat answers for anything that is not a link; `None`
443
+ (unknown, never "missing") and a link need the stat of what they
444
+ resolve to. A listed link that cannot be followed (a loop, an offline
445
+ volume) gets no stat, like a dangling one, so that `_sync` reports
446
+ that entry alone instead of the whole listing failing."""
447
+ try:
448
+ if listed is not None:
449
+ listed = FileStat.from_stat(listed)
450
+ if listed.size_known and not listed.is_symlink():
451
+ return PathAndStat.from_stat(child, listed)
452
+ return PathAndStat(child, follow_symlink=True)
453
+ except FileNotFoundError:
454
+ return PathAndStat.from_stat(child, None)
455
+ except OSError:
456
+ if FileStat.from_path(child, follow_symlink=False) is None:
457
+ raise
458
+ return PathAndStat.from_stat(child, None)
459
+
460
+ def sync(
461
+ self,
462
+ source: "Readable | PathAndStat",
463
+ target: Path | PathAndStat,
464
+ /,
465
+ dry_run: bool = False,
466
+ ignore_error: _OnPathSyncerError | bool | None = None,
467
+ ):
468
+ """Sync `source` onto `target`.
469
+
470
+ `ignore_error` overrides the instance-level policy for this call
471
+ only. It accepts a bool or a callable with the same
472
+ `(error, source, target, event)` arity as the constructor's; `None`
473
+ (the default) means "use the policy given to `__init__`".
474
+
475
+ The root call is checked before anything is touched: a `source`
476
+ that does not exist raises `FileNotFoundError`, and a `source` and
477
+ `target` of the same implementation where one contains the other
478
+ raise `ValueError`. Below the root, only a name the source listing
479
+ no longer holds takes the `remove_missing` path; a listed link that
480
+ cannot be resolved is an error for its entry. All go through
481
+ `ignore_error`; a tolerated root error ends the call without
482
+ changes, a tolerated entry error skips that entry. Child names that
483
+ would not stay a single component inside `target` (`..`, a
484
+ separator, drive, trailing dot or device name on a Windows target)
485
+ raise `ValueError` the same way, decided on the name as the source
486
+ or the target listed it, and symlinks found inside `target` are
487
+ replaced, never written, listed or deleted through. So is a Windows
488
+ junction below the root, which is another directory's second name:
489
+ `rmdir()` removes the junction and nothing behind it. A mount point
490
+ is part of the tree and is synced into. The root `target` the
491
+ caller named is used as given.
492
+ """
493
+ _ignore_error = (
494
+ self.ignore_error
495
+ if ignore_error is None
496
+ else _errorpolicy.as_error_handler(ignore_error)
497
+ )
498
+ root = target.path if isinstance(target, PathAndStat) else target
499
+ # One commit for the whole tree where the target pays for each write;
500
+ # a dry run writes nothing.
501
+ with _contextlib.nullcontext() if dry_run else _write_batch_of(root):
502
+ return self._sync(source, target, dry_run, _ignore_error, True, set(), {})
503
+
504
+ def _sync(
505
+ self,
506
+ source: "Readable | PathAndStat",
507
+ target: Path | PathAndStat,
508
+ dry_run: bool,
509
+ _ignore_error: _OnPathSyncerError,
510
+ root: bool,
511
+ swept: "set[str]",
512
+ listings: "dict[str, list]",
513
+ ):
514
+ checksum = self.checksum
515
+
516
+ def start():
517
+ nonlocal source, target
518
+ source = (
519
+ PathAndStat(source, follow_symlink=self.follow_symlinks)
520
+ if not isinstance(source, PathAndStat)
521
+ else source
522
+ )
523
+ # `follow_symlinks` describes the SOURCE traversal. Below the root a target entry
524
+ # is always lstat'd, so a link inside the destination is replaced, not followed
525
+ # out of it; the root target is the caller's and keeps their setting.
526
+ target = (
527
+ PathAndStat(
528
+ target, follow_symlink=self.follow_symlinks if root else False
529
+ )
530
+ if not isinstance(target, PathAndStat)
531
+ else target
532
+ )
533
+
534
+ # Stat first, then fire SyncStart: the hook is declared to receive
535
+ # PathAndStat entries, and the arguments of a combined call were
536
+ # evaluated before start() replaced the raw paths.
537
+ try:
538
+ start()
539
+ except Exception as error:
540
+ if self._tolerate(
541
+ _ignore_error, error, source, target, SyncEvent.SyncStart, dry_run
542
+ ):
543
+ return
544
+ raise
545
+ self.hook(source, target, SyncEvent.SyncStart, dry_run, None, _ignore_error)
546
+
547
+ if root:
548
+ error = None
549
+ if not source.exists():
550
+ # A typo, unmounted share or HTTP 404 must not read as "an
551
+ # empty source" and wipe the target under remove_missing.
552
+ error = FileNotFoundError(
553
+ _errno.ENOENT, "sync source does not exist", str(source.path)
554
+ )
555
+ elif _paths_overlap(source.path, target.path):
556
+ error = ValueError(
557
+ f"cannot sync {source.path} onto {target.path}: "
558
+ "source and target overlap"
559
+ )
560
+ if error is not None:
561
+ if not self._tolerate(
562
+ _ignore_error, error, source, target, SyncEvent.SyncStart, dry_run
563
+ ):
564
+ raise error
565
+ return
566
+
567
+ if not source.exists():
568
+ # A name the directory lists is never "missing": a link that
569
+ # cannot be resolved is an error for this entry, and the target
570
+ # entry of that name stays as it is.
571
+ unresolved = _unresolved_link(source)
572
+ if unresolved is not None:
573
+ error, link = unresolved
574
+ if not self._tolerate(
575
+ _ignore_error, error, link, target, SyncEvent.SyncStart, dry_run
576
+ ):
577
+ raise error
578
+ return
579
+ if self.remove_missing:
580
+ if self.hook(
581
+ source,
582
+ target,
583
+ SyncEvent.RemovedMissing,
584
+ dry_run,
585
+ lambda: target.path.rm(recursive=True, missing_ok=True),
586
+ _ignore_error,
587
+ ):
588
+ return
589
+ elif source.is_symlink():
590
+ if self.symlink_mode == "reject":
591
+ error = NotImplementedError("symlink sync not implemented yet")
592
+ if not self._tolerate(
593
+ _ignore_error, error, source, target, SyncEvent.Symlink, dry_run
594
+ ):
595
+ raise error
596
+ return
597
+
598
+ def create_symlink():
599
+ # Raw, unresolved target string, never resolved against
600
+ # source's parent -- a relative target stays relative.
601
+ raw_target = _link_text(source.path)
602
+ # Windows cannot traverse a file-type link to a directory,
603
+ # and os.symlink only guesses the kind when the link target
604
+ # already exists at the destination.
605
+ is_directory = _link_is_directory(source.path)
606
+
607
+ if not target.exists() and not target.is_symlink():
608
+ target.path.symlink_to(raw_target, is_directory)
609
+ return
610
+ # Type mismatch or a stale link: create the new link under a temporary sibling
611
+ # first, so a refusal at run time (no symlink privilege on Windows, a server
612
+ # permission error) fails before the existing entry is removed.
613
+ temp = _temp_sibling(target.path)
614
+ temp.symlink_to(raw_target, is_directory)
615
+ # Once the old entry is removed the temporary link is the
616
+ # only one: a failure after that keeps it.
617
+ removed = keep = False
618
+ try:
619
+ if target.is_file() or target.is_symlink():
620
+ target.path.unlink()
621
+ else:
622
+ target.path.rm(recursive=target.is_dir())
623
+ removed = True
624
+ try:
625
+ temp.rename(target.path)
626
+ except NotImplementedError:
627
+ target.path.symlink_to(raw_target, is_directory)
628
+ removed = False
629
+ except BaseException as error:
630
+ if not removed:
631
+ raise
632
+ keep = True
633
+ kept = _only_copy(error, temp, target.path)
634
+ if kept is error:
635
+ raise
636
+ raise kept from error
637
+ finally:
638
+ try:
639
+ if not keep and FileStat.from_path(temp, follow_symlink=False):
640
+ temp.unlink()
641
+ except Exception:
642
+ pass
643
+
644
+ # An identical link is left alone: recreating it gave it a new
645
+ # identity, left a window with no entry, and reported a change
646
+ # on every run.
647
+ if not (target.is_symlink() and _same_link(source.path, target.path)):
648
+ # Decided here and not inside create_symlink(), which a dry
649
+ # run never calls: both runs refuse a target that cannot
650
+ # hold links, before an existing entry is touched.
651
+ if not _supports_symlinks(target.path):
652
+ error = NotImplementedError(
653
+ f"symlink_to() not supported by {type(target.path).__name__}"
654
+ )
655
+ if not self._tolerate(
656
+ _ignore_error, error, source, target, SyncEvent.Symlink, dry_run
657
+ ):
658
+ raise error
659
+ return
660
+ if self._keeps_directory(source, target, dry_run, _ignore_error):
661
+ return
662
+ if target.exists() or target.is_symlink():
663
+ self._sweep(
664
+ source.path.parent,
665
+ target.path.parent,
666
+ dry_run,
667
+ _ignore_error,
668
+ swept,
669
+ listings,
670
+ )
671
+ if self.hook(
672
+ source,
673
+ target,
674
+ SyncEvent.Symlink,
675
+ dry_run,
676
+ create_symlink,
677
+ _ignore_error,
678
+ ):
679
+ return
680
+ elif source.is_file():
681
+ synced = False
682
+ if target.is_file():
683
+
684
+ def compare():
685
+ # quick_check: a cheap metadata-only pre-check for non-local pairs (see the class
686
+ # docstring); a match skips checksumming, a mismatch always falls through to a
687
+ # real checksum comparison and never concludes `changed` on its own.
688
+ if (
689
+ self.quick_check
690
+ and (not _is_local(source.path) or not _is_local(target.path))
691
+ and _quick_check_in_sync(source, target)
692
+ ):
693
+ return True
694
+ if checksum is _default_checksum:
695
+ # Through the paired native-versus-streaming policy (see the class docstring):
696
+ # only this branch can coordinate "both native or both streamed".
697
+ return _default_checksums_match(source, target)
698
+ return checksum(target) == checksum(source)
699
+
700
+ # Reported against this file pair, once: outside a hook an
701
+ # error here reached the policy only through every ancestor
702
+ # directory's hooks (and never for a root file pair).
703
+ try:
704
+ synced = compare()
705
+ except Exception as error:
706
+ if self._tolerate(
707
+ _ignore_error, error, source, target, SyncEvent.Compare, dry_run
708
+ ):
709
+ return
710
+ raise
711
+ elif self._keeps_directory(source, target, dry_run, _ignore_error):
712
+ return
713
+ if not synced:
714
+
715
+ def copy():
716
+ if target.is_file() and _supports(target.path, Move):
717
+ # Write the new content beside the target and rename
718
+ # it over: a failed or interrupted transfer leaves
719
+ # the previous version in place.
720
+ temp = _temp_sibling(target.path)
721
+ _LIVE_TEMPS.add(str(temp))
722
+ try:
723
+ source.path.copy(temp)
724
+ _replace(temp, target.path)
725
+ except BaseException as error:
726
+ # Once the previous version is gone the temp
727
+ # file is the only copy: it stays.
728
+ if not getattr(error, _ONLY_COPY_ATTR, False):
729
+ try:
730
+ temp.unlink(missing_ok=True)
731
+ except Exception:
732
+ pass
733
+ raise
734
+ finally:
735
+ _LIVE_TEMPS.discard(str(temp))
736
+ elif target.is_file():
737
+ # No rename: Path.copy() opens the source before it
738
+ # truncates the target, so a missing or unreadable
739
+ # source still leaves the target intact.
740
+ if _supports(source.path, Copy):
741
+ source.path.copy(target.path, overwrite=True)
742
+ else:
743
+ # A source that is only readable copies by streaming over the target.
744
+ source.path.copy(target.path)
745
+ else:
746
+ if target.is_symlink():
747
+ target.path.unlink()
748
+ elif target.exists():
749
+ target.path.rm(recursive=target.is_dir())
750
+ source.path.copy(target.path)
751
+ self._copy_mtime(source, target.path)
752
+
753
+ if target.is_file() and _supports(target.path, Move):
754
+ self._sweep(
755
+ source.path.parent,
756
+ target.path.parent,
757
+ dry_run,
758
+ _ignore_error,
759
+ swept,
760
+ listings,
761
+ )
762
+ if self.hook(
763
+ source, target, SyncEvent.Copy, dry_run, copy, _ignore_error
764
+ ):
765
+ return
766
+ elif source.is_dir():
767
+ # A symlink, junction, FIFO, socket or device inside the destination is replaced
768
+ # by a real directory, as writing or removing through a link would act on its
769
+ # target (unlink() / rmdir() remove the link itself). The root is used as given.
770
+ binding = (
771
+ not root
772
+ and target.is_dir()
773
+ and not target.is_symlink()
774
+ and _is_junction(target.path)
775
+ )
776
+ if (
777
+ not self.follow_mounts
778
+ and not root
779
+ and not binding
780
+ and target.is_dir()
781
+ and not target.is_symlink()
782
+ and _is_mount(target.path)
783
+ ):
784
+ # Another filesystem under this name: what is inside belongs
785
+ # to whoever mounted it, and the caller asked to stay out.
786
+ error = OSError(
787
+ _errno.EXDEV,
788
+ "refusing to sync into a mount point (follow_mounts is False)",
789
+ str(target.path),
790
+ )
791
+ if not self._tolerate(
792
+ _ignore_error,
793
+ error,
794
+ source,
795
+ target,
796
+ SyncEvent.TypeMismatch,
797
+ dry_run,
798
+ ):
799
+ raise error
800
+ return
801
+ if (
802
+ (target.is_symlink() and not root)
803
+ or binding
804
+ or (target.exists() and not target.is_dir() and not target.is_symlink())
805
+ ):
806
+ if self.hook(
807
+ source,
808
+ target,
809
+ SyncEvent.TypeMismatch,
810
+ dry_run,
811
+ target.path.rmdir if binding else target.path.unlink,
812
+ _ignore_error,
813
+ ):
814
+ return
815
+
816
+ target._stat = None
817
+
818
+ # A directory that did not exist (or only now exists, or only would in a dry
819
+ # run) has no children: nothing to list or remove, every child target is missing.
820
+ target_absent = not target.exists()
821
+ if target_absent:
822
+ if self.hook(
823
+ source,
824
+ target,
825
+ SyncEvent.CreatedDirectory,
826
+ dry_run,
827
+ lambda: target.path.mkdir(),
828
+ _ignore_error,
829
+ ):
830
+ return
831
+
832
+ source_children = None
833
+ windows_target = _names.is_windows_flavoured(target.path)
834
+ target_entries_cache = None
835
+
836
+ def target_entries():
837
+ # The existing target directory's children, listed once with their own
838
+ # (non-following) stat for the removal check, the stat of each synced child and
839
+ # the sweep. A failure is raised to the first caller only.
840
+ nonlocal target_entries_cache
841
+ if target_entries_cache is None:
842
+ target_entries_cache = ()
843
+ listing = list(_listing(target.path))
844
+ listings[str(target.path)] = listing
845
+ target_entries_cache = self._children(
846
+ target, windows_target, listing, follow=False
847
+ )
848
+ return target_entries_cache
849
+
850
+ def unsafe_name(name, source_entry, target_entry, event):
851
+ # A listed name that is not a single component inside `target` (`..`, or a
852
+ # backslash or colon on a Windows target) is never joined onto it; it is
853
+ # reported, not silently skipped.
854
+ if _names.is_safe_child_name(name, windows=windows_target):
855
+ return False
856
+ error = ValueError(
857
+ f"refusing unsafe child name {name!r} under {target.path}"
858
+ )
859
+ if not self._tolerate(
860
+ _ignore_error, error, source_entry, target_entry, event, dry_run
861
+ ):
862
+ raise error
863
+ return True
864
+
865
+ def get_source_children():
866
+ nonlocal source_children
867
+ if source_children is None:
868
+ source_children = self._children(source, windows_target)
869
+ return source_children
870
+
871
+ if self.remove_missing and not target_absent:
872
+
873
+ def checkchildren():
874
+ entries = target_entries()
875
+ self._sweep(
876
+ source.path,
877
+ target.path,
878
+ dry_run,
879
+ _ignore_error,
880
+ swept,
881
+ listings,
882
+ )
883
+ source_names = {name for name, _ in get_source_children()}
884
+ for name, child in entries:
885
+
886
+ def checkchild():
887
+ if unsafe_name(
888
+ name,
889
+ source,
890
+ child,
891
+ SyncEvent.RemovedMissing,
892
+ ):
893
+ return
894
+ if name not in source_names:
895
+ # The event describes the entry removed, not
896
+ # the directory being checked.
897
+ self.hook(
898
+ PathAndStat.from_stat(
899
+ _entries._child(source.path, name), None
900
+ ),
901
+ child,
902
+ SyncEvent.RemovedMissing,
903
+ dry_run,
904
+ lambda child=child: child.path.rm(recursive=True),
905
+ _ignore_error,
906
+ )
907
+
908
+ self.hook(
909
+ source,
910
+ target,
911
+ SyncEvent.CheckTargetChild,
912
+ dry_run,
913
+ checkchild,
914
+ _ignore_error,
915
+ always_run=True,
916
+ )
917
+
918
+ self.hook(
919
+ source,
920
+ target,
921
+ SyncEvent.CheckTargetChildren,
922
+ dry_run,
923
+ checkchildren,
924
+ _ignore_error,
925
+ always_run=True,
926
+ )
927
+
928
+ def target_child(name, listed):
929
+ # A name the listing did not report is stat'd by `start()`,
930
+ # not assumed missing: a listing can omit an entry, and a
931
+ # case-insensitive target answers to another spelling.
932
+ path = _entries._child(target.path, name)
933
+ if target_absent:
934
+ return PathAndStat.from_stat(path, None)
935
+ return listed.get(name, path)
936
+
937
+ def sync_children():
938
+ try:
939
+ listed = {}
940
+ if not target_absent:
941
+ try:
942
+ listed = dict(target_entries())
943
+ except Exception as error:
944
+ # Each child is stat'd on its own and reports
945
+ # its own failure.
946
+ _logger.debug("could not list %s: %s", target.path, error)
947
+ for name, child in get_source_children():
948
+ if unsafe_name(name, child, target, SyncEvent.SyncChild):
949
+ continue
950
+ self.hook(
951
+ source,
952
+ target,
953
+ SyncEvent.SyncChild,
954
+ dry_run,
955
+ # Propagate the resolved policy into the recursive
956
+ # call so a per-call override applies to the whole
957
+ # subtree, not just this level.
958
+ lambda child=child, name=name: self._sync(
959
+ child,
960
+ target_child(name, listed),
961
+ dry_run,
962
+ _ignore_error,
963
+ False,
964
+ swept,
965
+ listings,
966
+ ),
967
+ _ignore_error,
968
+ always_run=True,
969
+ )
970
+ finally:
971
+ listings.pop(str(target.path), None)
972
+
973
+ self.hook(
974
+ source,
975
+ target,
976
+ SyncEvent.SyncChildren,
977
+ dry_run,
978
+ sync_children,
979
+ _ignore_error,
980
+ always_run=True,
981
+ )
982
+ else:
983
+ # Not a file, directory or symlink: a FIFO, socket or device (or a stat without
984
+ # a file type). Nothing sensible can be copied; treating it as a directory
985
+ # would replace a same-named target file with an empty directory.
986
+ self.hook(source, target, SyncEvent.Skipped, dry_run, None, _ignore_error)
987
+ return
988
+
989
+ self.hook(source, target, SyncEvent.Synced, dry_run, None, _ignore_error)