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,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
+ )
@@ -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
@@ -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