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/_compare.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Whether a source and a target already agree: checksum and modification time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math as _math
|
|
6
|
+
|
|
7
|
+
from ..path import Path
|
|
8
|
+
from .. import checksum as _checksum
|
|
9
|
+
from ._events import PathAndStat
|
|
10
|
+
|
|
11
|
+
#: `PathSyncer`'s own default checksum policy (native digest preferred, streaming
|
|
12
|
+
#: fallback), which `sync()` special-cases (`_default_checksums_match()`); a
|
|
13
|
+
#: caller-supplied `checksum` is called as given, one path in, value out, with `==`.
|
|
14
|
+
_DEFAULT_ALGORITHM = "md5"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _default_checksum(entry: "PathAndStat") -> str:
|
|
18
|
+
# The sentinel that marks the default policy: `sync()` recognizes it and
|
|
19
|
+
# calls `_default_checksums_match()` instead. It is also the plain
|
|
20
|
+
# `Callable[[PathAndStat], Any]` that `PathSyncer().checksum` reads as.
|
|
21
|
+
native = _checksum.native(entry.path, _DEFAULT_ALGORITHM)
|
|
22
|
+
if native is not None:
|
|
23
|
+
return native
|
|
24
|
+
return _checksum.stream(entry.path, _DEFAULT_ALGORITHM)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _shared_native_algorithm(source: Path, target: Path) -> "str | None":
|
|
28
|
+
"""Pick an algorithm both sides advertise via
|
|
29
|
+
`NativeChecksum.supported_checksums()` (see `protocols/checksum.py`),
|
|
30
|
+
preferring `_DEFAULT_ALGORITHM` ("md5") when both sides support it,
|
|
31
|
+
`PathSyncer`'s default algorithm. Returns `None` if either side has no
|
|
32
|
+
`supported_checksums` at all (a `NativeChecksum` implementation isn't
|
|
33
|
+
required to override the advisory method -- the base default already
|
|
34
|
+
returns `frozenset()`, indistinguishable here from "doesn't implement
|
|
35
|
+
the protocol"), or if the two sides' advertised sets don't overlap.
|
|
36
|
+
"""
|
|
37
|
+
source_supported = getattr(source, "supported_checksums", None)
|
|
38
|
+
target_supported = getattr(target, "supported_checksums", None)
|
|
39
|
+
if source_supported is None or target_supported is None:
|
|
40
|
+
return None
|
|
41
|
+
shared = source_supported() & target_supported()
|
|
42
|
+
if not shared:
|
|
43
|
+
return None
|
|
44
|
+
if _DEFAULT_ALGORITHM in shared:
|
|
45
|
+
return _DEFAULT_ALGORITHM
|
|
46
|
+
return next(iter(shared))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _default_checksums_match(source: "PathAndStat", target: "PathAndStat") -> bool:
|
|
50
|
+
"""`PathSyncer`'s default in-sync check: prefer each side's
|
|
51
|
+
`NativeChecksum.checksum()` (no network transfer needed just to
|
|
52
|
+
*decide* whether a copy is needed), but only trust a native digest from
|
|
53
|
+
one side if the OTHER side can also produce a digest under the exact
|
|
54
|
+
same algorithm -- native or streamed. Mixing "native digest from A" with
|
|
55
|
+
"streamed digest from B" would only be numerically safe if both are
|
|
56
|
+
guaranteed true content hashes under the same algorithm; the protocol
|
|
57
|
+
contract for `NativeChecksum.checksum()` already guarantees that
|
|
58
|
+
(`NotImplementedError` on any doubt, e.g. S3 multipart ETags), so this
|
|
59
|
+
conservative both-native-or-both-streamed policy is a deliberate
|
|
60
|
+
simplicity choice, not a soundness requirement -- see
|
|
61
|
+
`protocols/checksum.py`.
|
|
62
|
+
|
|
63
|
+
Algorithm selection: try `supported_checksums()` intersection first
|
|
64
|
+
(`_shared_native_algorithm`) -- avoids a doomed native attempt when the
|
|
65
|
+
two sides' capabilities don't overlap on `_DEFAULT_ALGORITHM`. Falls
|
|
66
|
+
back to trying `_DEFAULT_ALGORITHM` directly (the pre-`supported_checksums`
|
|
67
|
+
behavior) when either side doesn't implement the advisory method at
|
|
68
|
+
all, since a `NativeChecksum` implementation is never required to
|
|
69
|
+
override it.
|
|
70
|
+
"""
|
|
71
|
+
# Whether each side can answer at all is known without I/O: ask for a
|
|
72
|
+
# digest only when both can, and ask the second only if the first did,
|
|
73
|
+
# so a digest that would be thrown away is never computed.
|
|
74
|
+
if all(getattr(entry.path, "checksum", None) for entry in (source, target)):
|
|
75
|
+
algorithm = (
|
|
76
|
+
_shared_native_algorithm(source.path, target.path) or _DEFAULT_ALGORITHM
|
|
77
|
+
)
|
|
78
|
+
source_native = _checksum.native(source.path, algorithm)
|
|
79
|
+
if source_native is not None:
|
|
80
|
+
target_native = _checksum.native(target.path, algorithm)
|
|
81
|
+
if target_native is not None:
|
|
82
|
+
return source_native == target_native
|
|
83
|
+
return _checksum.stream(source.path, _DEFAULT_ALGORITHM) == _checksum.stream(
|
|
84
|
+
target.path, _DEFAULT_ALGORITHM
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_local(path) -> bool:
|
|
89
|
+
"""`quick_check`'s locality test. `is_local()` is an optional method of a
|
|
90
|
+
path whose server may be this machine or another one (a URI path, an SFTP
|
|
91
|
+
path); a plain `LocalPath` or `MemPath` has no such method at all. Both of
|
|
92
|
+
those ARE effectively local for this purpose (a real local filesystem, or
|
|
93
|
+
an in-memory structure with no network cost either way), so "no
|
|
94
|
+
`is_local()` method" is treated as local -- the safe default, since it
|
|
95
|
+
only preserves the always-checksum behavior rather than skipping a
|
|
96
|
+
comparison it shouldn't.
|
|
97
|
+
|
|
98
|
+
An `is_local()` that resolves a host name can fail with `OSError` when the
|
|
99
|
+
resolver itself fails (no network at all), as opposed to a name that
|
|
100
|
+
resolves nowhere, which answers `False`. That is treated as "local" for
|
|
101
|
+
the same safe-default reason: quick_check does not kick in and the sync
|
|
102
|
+
falls through to a real checksum comparison, rather than letting an
|
|
103
|
+
unrelated resolver failure crash the sync outright.
|
|
104
|
+
"""
|
|
105
|
+
is_local = getattr(path, "is_local", None)
|
|
106
|
+
if is_local is None:
|
|
107
|
+
return True
|
|
108
|
+
try:
|
|
109
|
+
return is_local()
|
|
110
|
+
except OSError:
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
#: Two floats of one instant differ by less than this after a filesystem has
|
|
115
|
+
#: stored them as nanoseconds and handed them back.
|
|
116
|
+
_MTIME_TICK = 1e-6
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _same_mtime(first: float, second: float) -> bool:
|
|
120
|
+
"""Whether two modification times name the same instant at the resolution
|
|
121
|
+
of the coarser: whole seconds when either has no fractional part (SFTP,
|
|
122
|
+
FTP and the object stores report seconds, a local filesystem fractions),
|
|
123
|
+
else to a microsecond."""
|
|
124
|
+
if first == second:
|
|
125
|
+
return True
|
|
126
|
+
if first % 1 == 0 or second % 1 == 0:
|
|
127
|
+
return _math.floor(first) == _math.floor(second)
|
|
128
|
+
return abs(first - second) < _MTIME_TICK
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _quick_check_in_sync(source: "PathAndStat", target: "PathAndStat") -> bool:
|
|
132
|
+
"""The rsync-style "quick check" pre-check: True only when BOTH
|
|
133
|
+
`st_size` and `st_mtime` already match between the two cached stats --
|
|
134
|
+
metadata `PathAndStat` already carries from listing, no extra round
|
|
135
|
+
trip. Never used to conclude "out of sync" (a caller must fall through
|
|
136
|
+
to a real checksum comparison on any mismatch) -- see `PathSyncer`'s
|
|
137
|
+
class docstring for why.
|
|
138
|
+
|
|
139
|
+
An `st_mtime` of 0 (or missing) on either side is "unknown", never a
|
|
140
|
+
timestamp: MemPath, GitHub/GitLab, `data:` and HTTP without
|
|
141
|
+
Last-Modified all report 0, and two unknowns are not a match. So is a
|
|
142
|
+
stat whose listing did not report a size (`size_known` False). Times
|
|
143
|
+
match as `_same_mtime()` says.
|
|
144
|
+
"""
|
|
145
|
+
source_stat, target_stat = source.stat, target.stat
|
|
146
|
+
if source_stat is None or target_stat is None:
|
|
147
|
+
return False
|
|
148
|
+
if not (
|
|
149
|
+
getattr(source_stat, "size_known", True)
|
|
150
|
+
and getattr(target_stat, "size_known", True)
|
|
151
|
+
):
|
|
152
|
+
return False
|
|
153
|
+
if not source_stat.st_mtime or not target_stat.st_mtime:
|
|
154
|
+
return False
|
|
155
|
+
return source_stat.st_size == target_stat.st_size and _same_mtime(
|
|
156
|
+
source_stat.st_mtime, target_stat.st_mtime
|
|
157
|
+
)
|
pathapi/sync/_events.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The events a sync reports and the path-and-stat pair it works on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum as _enum
|
|
6
|
+
import stat as _stat
|
|
7
|
+
import typing as _ty
|
|
8
|
+
|
|
9
|
+
from ..path import Path
|
|
10
|
+
from ..protocols._bundles import Readable
|
|
11
|
+
from .._filestat import FileStat
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SyncEvent(_enum.Enum):
|
|
15
|
+
"""Events `PathSyncer.hook()` fires during a sync, for progress/logging
|
|
16
|
+
callbacks."""
|
|
17
|
+
|
|
18
|
+
Copy = _enum.auto()
|
|
19
|
+
RemovedMissing = _enum.auto()
|
|
20
|
+
Synced = _enum.auto()
|
|
21
|
+
CreatedDirectory = _enum.auto()
|
|
22
|
+
SyncStart = _enum.auto()
|
|
23
|
+
TypeMismatch = _enum.auto()
|
|
24
|
+
CheckTargetChild = _enum.auto()
|
|
25
|
+
CheckTargetChildren = _enum.auto()
|
|
26
|
+
SyncChild = _enum.auto()
|
|
27
|
+
SyncChildren = _enum.auto()
|
|
28
|
+
Symlink = _enum.auto()
|
|
29
|
+
#: Comparing a source/target file pair (quick check or checksum)
|
|
30
|
+
#: failed. Only ever passed to `ignore_error`; no hook fires for it.
|
|
31
|
+
Compare = _enum.auto()
|
|
32
|
+
#: The source entry is neither a regular file, a directory nor a
|
|
33
|
+
#: symlink (a FIFO, socket or device, or a stat with no file type): it is
|
|
34
|
+
#: skipped and the target is left untouched.
|
|
35
|
+
Skipped = _enum.auto()
|
|
36
|
+
#: `ignore_error` tolerated an error for this source/target pair. Fired
|
|
37
|
+
#: after the policy returned True; the failing event and the exception
|
|
38
|
+
#: are in the WARNING record logged on the `pathapi.sync` logger.
|
|
39
|
+
Error = _enum.auto()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PathAndStat(object):
|
|
43
|
+
"""A `Path` plus its cached `stat()` result (`None` if it doesn't
|
|
44
|
+
exist). `is_*` attribute access (e.g. `.is_file()`) delegates to the
|
|
45
|
+
cached stat, returning a false-returning callable if the path doesn't
|
|
46
|
+
exist; any other unknown attribute raises `AttributeError` as normal.
|
|
47
|
+
|
|
48
|
+
`follow_symlink` (in `__init__` and `refresh()`) defaults to `True`,
|
|
49
|
+
like `pathlib.Path.stat()` and `FileStat.from_path()`: a symlink is
|
|
50
|
+
described by what it points at. Pass `False` for the link itself
|
|
51
|
+
(`lstat()`); `PathSyncer` always passes its `follow_symlinks` setting."""
|
|
52
|
+
|
|
53
|
+
__slots__ = ("_path", "_stat")
|
|
54
|
+
|
|
55
|
+
def __init__(self, path: "Readable", *, follow_symlink: bool = True) -> None:
|
|
56
|
+
self._path = path
|
|
57
|
+
self.refresh(follow_symlink)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_stat(cls, path: "Readable", stat: FileStat | None) -> "PathAndStat":
|
|
61
|
+
entry = cls.__new__(cls)
|
|
62
|
+
entry._path = path
|
|
63
|
+
entry._stat = stat
|
|
64
|
+
return entry
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str:
|
|
67
|
+
return str(self.path)
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return str((self.path, self._stat))
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def path(self):
|
|
74
|
+
return self._path
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def stat(self):
|
|
78
|
+
return self._stat
|
|
79
|
+
|
|
80
|
+
def exists(self):
|
|
81
|
+
return self.stat is not None
|
|
82
|
+
|
|
83
|
+
def refresh(self, follow_symlink: bool = True):
|
|
84
|
+
self._stat = FileStat.from_path(self.path, follow_symlink=follow_symlink)
|
|
85
|
+
|
|
86
|
+
def __getattr__(self, name: str):
|
|
87
|
+
if name.startswith("is_"):
|
|
88
|
+
if self.stat:
|
|
89
|
+
return getattr(self.stat, name)
|
|
90
|
+
else:
|
|
91
|
+
return lambda *args, **kwargs: False
|
|
92
|
+
raise AttributeError(name)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if _ty.TYPE_CHECKING:
|
|
96
|
+
|
|
97
|
+
class PathAndStat(PathAndStat, FileStat): ...
|
pathapi/sync/_links.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Symbolic links in a sync: whether they can be made and whether two agree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno as _errno
|
|
6
|
+
import os as _os
|
|
7
|
+
import pathlib as _pathlib
|
|
8
|
+
import stat as _stat
|
|
9
|
+
|
|
10
|
+
from ..path import Path
|
|
11
|
+
from ..protocols import Symlink, supports
|
|
12
|
+
from .._filestat import FileStat
|
|
13
|
+
from ._events import PathAndStat
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _supports_symlinks(path: Path) -> bool:
|
|
17
|
+
"""Whether `path`'s class makes links. Decided on the class, before
|
|
18
|
+
anything is touched; a backend that implements the primitive can still
|
|
19
|
+
fail at runtime."""
|
|
20
|
+
return supports(path, Symlink)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _link_text(path: Path) -> str:
|
|
24
|
+
"""The raw, unresolved target string of the symlink at `path`. A path
|
|
25
|
+
that has a `.path` attribute (a URI path) holds the un-prefixed link text there,
|
|
26
|
+
since `as_posix()` prepends "host:" when the result carries a host; plain
|
|
27
|
+
stdlib Path has no `.path`, so `.as_posix()` is the only accessor there.
|
|
28
|
+
Both sides of a comparison must use this same extraction."""
|
|
29
|
+
link = path.readlink()
|
|
30
|
+
return link.path if hasattr(link, "path") else link.as_posix()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_windows_local(path: Path) -> bool:
|
|
34
|
+
return _os.name == "nt" and isinstance(path, _pathlib.Path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _link_is_directory(path: Path) -> bool:
|
|
38
|
+
"""Whether a symlink at `path` is (or should be recreated as) a
|
|
39
|
+
directory link. On Windows a local link records its own kind, dangling
|
|
40
|
+
or not, in its attributes; elsewhere the kind is what the link resolves
|
|
41
|
+
to (False when dangling), which only Windows targets act on."""
|
|
42
|
+
if _is_windows_local(path):
|
|
43
|
+
attributes = getattr(_os.lstat(path), "st_file_attributes", 0)
|
|
44
|
+
return bool(attributes & _stat.FILE_ATTRIBUTE_DIRECTORY)
|
|
45
|
+
try:
|
|
46
|
+
return bool(path.is_dir())
|
|
47
|
+
except (OSError, NotImplementedError):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _same_link(source: Path, target: Path) -> bool:
|
|
52
|
+
"""Whether the symlink `target` already matches the symlink `source`:
|
|
53
|
+
the same raw target string, and on a Windows target the same link kind
|
|
54
|
+
(a file link to a directory cannot be traversed). Any failure to read
|
|
55
|
+
either side means "not known to match", so the link is replaced."""
|
|
56
|
+
try:
|
|
57
|
+
if _link_text(source) != _link_text(target):
|
|
58
|
+
return False
|
|
59
|
+
if _is_windows_local(target):
|
|
60
|
+
return _link_is_directory(source) == _link_is_directory(target)
|
|
61
|
+
return True
|
|
62
|
+
except (OSError, NotImplementedError):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _as_entry(entry) -> "PathAndStat":
|
|
67
|
+
# Hooks and error policies are declared to receive PathAndStat; a raw
|
|
68
|
+
# path that never got stat'd (its stat() is what failed) is reported with
|
|
69
|
+
# an unknown stat.
|
|
70
|
+
if isinstance(entry, PathAndStat):
|
|
71
|
+
return entry
|
|
72
|
+
return PathAndStat.from_stat(entry, None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _unresolved_link(entry: "PathAndStat") -> "tuple[Exception, PathAndStat] | None":
|
|
76
|
+
"""For a source entry whose followed stat found nothing: the error to
|
|
77
|
+
offer and the entry as the link itself, when the directory still lists
|
|
78
|
+
something there that cannot be resolved (a dangling link, a loop, an
|
|
79
|
+
unmounted volume). None when nothing is left at that name, so it
|
|
80
|
+
vanished after the listing. Never an instruction to delete anything."""
|
|
81
|
+
link = FileStat.from_path(entry.path, follow_symlink=False)
|
|
82
|
+
if link is None:
|
|
83
|
+
return None
|
|
84
|
+
error = None
|
|
85
|
+
try:
|
|
86
|
+
entry.path.stat()
|
|
87
|
+
except OSError as caught:
|
|
88
|
+
error = caught
|
|
89
|
+
if error is None or isinstance(error, FileNotFoundError):
|
|
90
|
+
error = FileNotFoundError(
|
|
91
|
+
_errno.ENOENT, "the link's target does not exist", str(entry.path)
|
|
92
|
+
)
|
|
93
|
+
return error, PathAndStat.from_stat(entry.path, link)
|
pathapi/sync/_offer.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Offering a failure to the caller's error policy once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
#: Set on an exception once `ignore_error` has declined it, so every
|
|
6
|
+
#: enclosing hook re-raises it instead of consulting the policy again with an
|
|
7
|
+
#: ancestor's paths.
|
|
8
|
+
_OFFERED_ATTR = "_pathapi_sync_offered"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _offer_error(policy, error: Exception, source, target, event) -> bool:
|
|
12
|
+
"""Ask `policy` about `error` once. True means "tolerated". An error the
|
|
13
|
+
policy already declined (deeper in the tree) is re-raised by every outer
|
|
14
|
+
level without asking again."""
|
|
15
|
+
if getattr(error, _OFFERED_ATTR, False):
|
|
16
|
+
return False
|
|
17
|
+
try:
|
|
18
|
+
if policy(error, source, target, event):
|
|
19
|
+
return True
|
|
20
|
+
except BaseException as raised:
|
|
21
|
+
# The policy's own exception ends the sync: an enclosing level must
|
|
22
|
+
# not offer it back to the policy as a failure of its own.
|
|
23
|
+
_mark_offered(raised)
|
|
24
|
+
raise
|
|
25
|
+
_mark_offered(error)
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _mark_offered(error: BaseException) -> None:
|
|
30
|
+
try:
|
|
31
|
+
setattr(error, _OFFERED_ATTR, True)
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
pathapi/sync/_paths.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Questions about the paths of a sync: junctions, mounts, names, overlap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..path import Path
|
|
6
|
+
from ..protocols._relation import _relation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _is_junction(path: Path) -> bool:
|
|
10
|
+
"""Whether `path` is a directory junction: a link to another directory
|
|
11
|
+
that lists as an ordinary one. A mount point is not one, even where the
|
|
12
|
+
platform builds both from the same reparse point: it is a filesystem
|
|
13
|
+
boundary inside the tree, and a sync writes into it."""
|
|
14
|
+
try:
|
|
15
|
+
if not path.is_junction():
|
|
16
|
+
return False
|
|
17
|
+
except (NotImplementedError, OSError, ValueError):
|
|
18
|
+
return False
|
|
19
|
+
try:
|
|
20
|
+
return not path.is_mount()
|
|
21
|
+
except (NotImplementedError, OSError, ValueError):
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_mount(path: Path) -> bool:
|
|
26
|
+
"""Whether `path` is a mount point (a mounted volume, a bind mount); a
|
|
27
|
+
class that cannot answer says no."""
|
|
28
|
+
try:
|
|
29
|
+
return bool(path.is_mount())
|
|
30
|
+
except (NotImplementedError, OSError, ValueError):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _child_name(path: Path) -> str:
|
|
35
|
+
# A directory child of a URI path can end in "/", leaving `.name`
|
|
36
|
+
# empty; its last real component is then the parent's name.
|
|
37
|
+
return path.name or path.parent.name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _paths_overlap(source: Path, target: Path) -> bool:
|
|
41
|
+
"""Whether `source` and `target` are the same tree, one contains the
|
|
42
|
+
other, or they are two names of one file. Decided by `path._relation()`:
|
|
43
|
+
two files of this machine by where each really is (a symlink cannot hide
|
|
44
|
+
the overlap), whatever their classes; a type answering `_node_key()`
|
|
45
|
+
(`MemPath`, archive members) by the node, across classes and spellings;
|
|
46
|
+
anything else only between two paths of one type that
|
|
47
|
+
`_same_filesystem()` places together, so two `MemPath` trees on separate
|
|
48
|
+
backends, or two hosts behind one path type, do not overlap even with
|
|
49
|
+
equal segments."""
|
|
50
|
+
return _relation(source, target, follow=True, identity=False) is not None
|
pathapi/sync/_replace.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Writing a target through a temporary sibling and swapping it in."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pathlib as _pathlib
|
|
6
|
+
import re as _re
|
|
7
|
+
import uuid as _uuid
|
|
8
|
+
|
|
9
|
+
from ..path import Path
|
|
10
|
+
from .._filestat import FileStat
|
|
11
|
+
from ._paths import _child_name
|
|
12
|
+
|
|
13
|
+
#: The longest name of one directory entry, in UTF-8 bytes, that NTFS and the
|
|
14
|
+
#: usual POSIX filesystems accept (NTFS counts UTF-16 units, never more).
|
|
15
|
+
_NAME_MAX = 255
|
|
16
|
+
|
|
17
|
+
#: A temporary sibling is `.NAME.<12 hex digits>.pathapi-tmp`, with NAME
|
|
18
|
+
#: cut short when the whole would not fit in `_NAME_MAX`. Only a name matching
|
|
19
|
+
#: all of this is the library's own.
|
|
20
|
+
_LEFTOVER = _re.compile(r"\..+\.[0-9a-f]{12}\.pathapi-tmp")
|
|
21
|
+
|
|
22
|
+
#: A leftover is stale once it has not been modified for this long (seconds).
|
|
23
|
+
#: A transfer that is running writes to its file continuously, so a younger
|
|
24
|
+
#: one may belong to a sync running now, in this process or in another.
|
|
25
|
+
_STALE_AFTER = 24 * 60 * 60
|
|
26
|
+
|
|
27
|
+
#: Temporary files (`str(path)`) this process is writing now: never stale,
|
|
28
|
+
#: whatever the store reports as their modification time.
|
|
29
|
+
_LIVE_TEMPS: "set[str]" = set()
|
|
30
|
+
|
|
31
|
+
#: Set on an exception raised while the previous version of a file is gone
|
|
32
|
+
#: and the new one exists only under its temporary name.
|
|
33
|
+
_ONLY_COPY_ATTR = "_pathapi_sync_only_copy"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_leftover(name: str) -> bool:
|
|
37
|
+
return _LEFTOVER.fullmatch(name) is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _encoded_length(name: str) -> int:
|
|
41
|
+
return len(name.encode("utf-8", "surrogatepass"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _temp_sibling(path: Path) -> Path:
|
|
45
|
+
# Hidden and unique, in the same directory so a rename stays on one
|
|
46
|
+
# filesystem/connection. The embedded name is cut (never the random part
|
|
47
|
+
# or the suffix) so that a name the target accepts still gets a sibling.
|
|
48
|
+
tail = f".{_uuid.uuid4().hex[:12]}.pathapi-tmp"
|
|
49
|
+
room = _NAME_MAX - 1 - len(tail)
|
|
50
|
+
name = _child_name(path)[:room]
|
|
51
|
+
while _encoded_length(name) > room:
|
|
52
|
+
name = name[:-1]
|
|
53
|
+
return path.with_name(f".{name}{tail}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _only_copy(error: BaseException, temp: Path, target: Path) -> BaseException:
|
|
57
|
+
"""`error`, marked so that the clean-up keeps `temp`, and, for an
|
|
58
|
+
`Exception`, replaced by an `OSError` naming `temp` (the kind follows the
|
|
59
|
+
errno, as `OSError()` maps it). Used once the previous `target` is gone,
|
|
60
|
+
when `temp` is the only copy of the new version."""
|
|
61
|
+
kept = error
|
|
62
|
+
if isinstance(error, Exception):
|
|
63
|
+
detail = (
|
|
64
|
+
f"{target} was removed to make room for the new version, which is "
|
|
65
|
+
f"kept at {temp}: {error}"
|
|
66
|
+
)
|
|
67
|
+
errno = getattr(error, "errno", None)
|
|
68
|
+
kept = OSError(errno, detail) if errno else OSError(detail)
|
|
69
|
+
try:
|
|
70
|
+
setattr(kept, _ONLY_COPY_ATTR, True)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
return kept
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _replace(source: Path, target: Path) -> None:
|
|
77
|
+
"""Rename `source` over the existing `target`. `os.replace` locally
|
|
78
|
+
(atomic on POSIX and Windows); elsewhere `rename()`, which replaces on
|
|
79
|
+
backends with POSIX rename semantics, and otherwise refuses -- then the
|
|
80
|
+
old target is removed first, after the new content is complete. If the
|
|
81
|
+
second rename then fails `source` is the only copy: it is kept, and the
|
|
82
|
+
error names it."""
|
|
83
|
+
if isinstance(source, _pathlib.Path):
|
|
84
|
+
source.replace(target)
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
source.rename(target)
|
|
88
|
+
except OSError:
|
|
89
|
+
if FileStat.from_path(target, follow_symlink=False) is None:
|
|
90
|
+
raise
|
|
91
|
+
target.unlink()
|
|
92
|
+
try:
|
|
93
|
+
source.rename(target)
|
|
94
|
+
except BaseException as error:
|
|
95
|
+
kept = _only_copy(error, source, target)
|
|
96
|
+
if kept is error:
|
|
97
|
+
raise
|
|
98
|
+
raise kept from error
|