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.
- pathapi/AGENTS.md +2355 -0
- pathapi/README.md +205 -0
- pathapi/__init__.py +62 -0
- pathapi/_commit.py +116 -0
- pathapi/_discard.py +92 -0
- pathapi/_errorpolicy.py +106 -0
- pathapi/_filestat.py +194 -0
- pathapi/_lru.py +138 -0
- pathapi/_names.py +70 -0
- pathapi/_permissions.py +102 -0
- pathapi/_stubs.py +76 -0
- pathapi/_tree.py +136 -0
- pathapi/archive/__init__.py +44 -0
- pathapi/archive/_backend.py +393 -0
- pathapi/archive/_names.py +63 -0
- pathapi/archive/_pack.py +394 -0
- pathapi/archive/_path.py +352 -0
- pathapi/archive/_snapshot.py +269 -0
- pathapi/archive/_tar.py +126 -0
- pathapi/archive/_write.py +359 -0
- pathapi/archive/_zip.py +563 -0
- pathapi/archive/_zipentries.py +272 -0
- pathapi/checksum.py +61 -0
- pathapi/exceptions.py +22 -0
- pathapi/fspath.py +513 -0
- pathapi/ftp/__init__.py +27 -0
- pathapi/ftp/_backend.py +418 -0
- pathapi/ftp/_facts.py +117 -0
- pathapi/ftp/_path.py +582 -0
- pathapi/glob/__init__.py +27 -0
- pathapi/glob/_entries.py +103 -0
- pathapi/glob/_patterns.py +164 -0
- pathapi/glob/_recursion.py +180 -0
- pathapi/glob/_selection.py +303 -0
- pathapi/http/__init__.py +84 -0
- pathapi/http/_body.py +156 -0
- pathapi/http/_builtin.py +553 -0
- pathapi/http/_client.py +211 -0
- pathapi/http/_errors.py +79 -0
- pathapi/http/_follow.py +230 -0
- pathapi/http/_headers.py +233 -0
- pathapi/http/_pool.py +169 -0
- pathapi/http/_proxies.py +142 -0
- pathapi/http/_response.py +255 -0
- pathapi/http/_settings.py +12 -0
- pathapi/http/_status.py +68 -0
- pathapi/http/_transports.py +173 -0
- pathapi/http/testing/__init__.py +80 -0
- pathapi/http/testing/__main__.py +10 -0
- pathapi/http/testing/suite/conftest.py +98 -0
- pathapi/http/testing/suite/test_client_default.py +285 -0
- pathapi/http/testing/suite/test_client_errors.py +181 -0
- pathapi/http/testing/suite/test_client_peer_text.py +86 -0
- pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
- pathapi/http/testing/suite/test_client_redirects.py +231 -0
- pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
- pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
- pathapi/http/testing/suite/test_transport_body.py +302 -0
- pathapi/http/testing/suite/test_transport_send.py +662 -0
- pathapi/http/testing/suite/transport_fakes.py +133 -0
- pathapi/http/testing/suite/transport_guard.py +174 -0
- pathapi/http/testing/suite/transport_wire.py +511 -0
- pathapi/mempath.py +438 -0
- pathapi/path/__init__.py +19 -0
- pathapi/path/_info.py +10 -0
- pathapi/path/_path.py +198 -0
- pathapi/path/_pure.py +522 -0
- pathapi/path/_relation.py +10 -0
- pathapi/path/_transfer.py +10 -0
- pathapi/protocols/__init__.py +35 -0
- pathapi/protocols/_bundles.py +22 -0
- pathapi/protocols/_errors.py +12 -0
- pathapi/protocols/_info.py +65 -0
- pathapi/protocols/_relation.py +191 -0
- pathapi/protocols/_removal.py +69 -0
- pathapi/protocols/_support.py +122 -0
- pathapi/protocols/_transfer.py +100 -0
- pathapi/protocols/checksum.py +80 -0
- pathapi/protocols/fs.py +429 -0
- pathapi/protocols/io.py +224 -0
- pathapi/protocols/links.py +135 -0
- pathapi/protocols/pure.py +31 -0
- pathapi/protocols/resolve.py +97 -0
- pathapi/protocols/transfer.py +656 -0
- pathapi/protocols/tree.py +492 -0
- pathapi/providers.py +48 -0
- pathapi/py.typed +1 -0
- pathapi/sync/__init__.py +8 -0
- pathapi/sync/_compare.py +157 -0
- pathapi/sync/_events.py +97 -0
- pathapi/sync/_links.py +93 -0
- pathapi/sync/_offer.py +33 -0
- pathapi/sync/_paths.py +50 -0
- pathapi/sync/_replace.py +98 -0
- pathapi/sync/_syncer.py +989 -0
- pathapi/testing/__init__.py +96 -0
- pathapi/testing/_contract.py +522 -0
- pathapi/testing/_fixtures.py +83 -0
- pathapi/testing/_read.py +279 -0
- pathapi-0.1.0.dist-info/METADATA +242 -0
- pathapi-0.1.0.dist-info/RECORD +104 -0
- pathapi-0.1.0.dist-info/WHEEL +4 -0
- pathapi-0.1.0.dist-info/entry_points.txt +2 -0
- pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
pathapi/sync/_syncer.py
ADDED
|
@@ -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)
|