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/archive/_path.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno as _errno
|
|
4
|
+
import os as _os
|
|
5
|
+
import urllib.parse as _urlparse
|
|
6
|
+
|
|
7
|
+
from .._tree import TreePath
|
|
8
|
+
from ..fspath import LocalPath
|
|
9
|
+
from ..path import Path
|
|
10
|
+
from .._names import is_safe_child_name
|
|
11
|
+
from ._backend import (
|
|
12
|
+
_ArchiveBackend,
|
|
13
|
+
_UndecidedBackend,
|
|
14
|
+
_get_backend,
|
|
15
|
+
)
|
|
16
|
+
from ._names import _normalize_member_name
|
|
17
|
+
from ._snapshot import LinkTarget, _Snapshot, _Unresolved
|
|
18
|
+
from ._tar import _TarBackend
|
|
19
|
+
from ._write import _WritableMembers
|
|
20
|
+
from ._zip import _ZipBackend
|
|
21
|
+
|
|
22
|
+
_SEP = "!/"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _outer_file(archive: object) -> Path:
|
|
26
|
+
"""The archive file as a `Path`: a path of this library as it is, a `str`
|
|
27
|
+
or any other `os.PathLike` as a `LocalPath`."""
|
|
28
|
+
if isinstance(archive, Path):
|
|
29
|
+
return archive
|
|
30
|
+
if isinstance(archive, (str, _os.PathLike)):
|
|
31
|
+
return LocalPath(archive)
|
|
32
|
+
raise TypeError(
|
|
33
|
+
"archive should be a Path, a str or an os.PathLike, "
|
|
34
|
+
f"not {type(archive).__name__!r}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _rebuild_member(cls, text, archive):
|
|
39
|
+
return cls(text, archive=archive)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ArchivePath(_WritableMembers, TreePath):
|
|
43
|
+
"""A member path of an archive file, which is any `Path` (a `LocalPath`, a
|
|
44
|
+
`MemPath`, a path on a remote store). The segments are the member path; the
|
|
45
|
+
archive's format, zip or tar, is read from its extension or its first bytes
|
|
46
|
+
on first use, and `ZipPath` and `TarPath` fix it. Building a path, printing
|
|
47
|
+
it and joining names onto it read nothing.
|
|
48
|
+
|
|
49
|
+
A zip archive that is a local file can be written (new members,
|
|
50
|
+
`unlink`/`rmdir`/`rename`, `batch()`); every other archive is read-only.
|
|
51
|
+
One backend serves each archive file and format, so paths built separately
|
|
52
|
+
for one file see each other's writes."""
|
|
53
|
+
|
|
54
|
+
__slots__ = ("_backend",)
|
|
55
|
+
_SCHEME = "archive"
|
|
56
|
+
_backend_cls: "type | None" = None
|
|
57
|
+
|
|
58
|
+
def __init__(self, *segments: "str | ArchivePath", archive: object = None):
|
|
59
|
+
text, donor = self._join(segments)
|
|
60
|
+
if archive is not None:
|
|
61
|
+
backend = self.backend_for(archive)
|
|
62
|
+
elif donor is not None:
|
|
63
|
+
backend = donor._backend
|
|
64
|
+
else:
|
|
65
|
+
raise TypeError("an archive path needs archive=")
|
|
66
|
+
self._segments = self._parse(text)
|
|
67
|
+
self._backend = backend
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def backend_for(cls, archive: object, *, shared: bool = True, identity=None):
|
|
71
|
+
"""The backend that serves `archive`, to pass as `archive=` of any path
|
|
72
|
+
of the class: the backend of another path of the same archive is
|
|
73
|
+
returned as it is. With `shared` the one backend of that archive file
|
|
74
|
+
and format (found by its real path for a local file, else by `identity`
|
|
75
|
+
or the node or URI of the path); with `shared=False` a backend of its
|
|
76
|
+
own. Reads nothing: the format of `ArchivePath` is decided on first
|
|
77
|
+
use."""
|
|
78
|
+
if isinstance(archive, (_ArchiveBackend, _UndecidedBackend)):
|
|
79
|
+
return archive
|
|
80
|
+
outer = _outer_file(archive)
|
|
81
|
+
if cls._backend_cls is None:
|
|
82
|
+
return _UndecidedBackend(outer, shared, identity)
|
|
83
|
+
if not shared:
|
|
84
|
+
return cls._backend_cls(outer)
|
|
85
|
+
return _get_backend(cls._backend_cls, outer, identity)
|
|
86
|
+
|
|
87
|
+
def _rebuild(self, text: str) -> "ArchivePath":
|
|
88
|
+
return type(self)(text, archive=self._backend)
|
|
89
|
+
|
|
90
|
+
def __reduce__(self):
|
|
91
|
+
# The text and the archive file; the handle is found again by the
|
|
92
|
+
# registry, and a pickle holds no open file.
|
|
93
|
+
return _rebuild_member, (type(self), self.as_posix(), self.archive)
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
return f"{type(self).__name__}({self.as_posix()!r}, archive={self.archive!r})"
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def archive(self) -> Path:
|
|
100
|
+
"""The archive file. Never decides the format."""
|
|
101
|
+
return self._backend.outer
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def backend(self) -> _ArchiveBackend:
|
|
105
|
+
"""The handle on the archive, shared by every path of it: settles the
|
|
106
|
+
format of an `ArchivePath` on the first call, which reads the archive
|
|
107
|
+
when its extension does not say."""
|
|
108
|
+
backend = self._backend
|
|
109
|
+
if isinstance(backend, _UndecidedBackend):
|
|
110
|
+
backend = backend.resolve()
|
|
111
|
+
return backend
|
|
112
|
+
|
|
113
|
+
def refresh(self) -> None:
|
|
114
|
+
"""Forgets what is held of the archive file, so the next use reads it
|
|
115
|
+
again. A non-local archive is read once and kept while any path to it
|
|
116
|
+
lives, so this is how a later change at its source is seen; a local one
|
|
117
|
+
is checked against the file on every use. Every path to the same
|
|
118
|
+
archive shares the refresh."""
|
|
119
|
+
backend = self._backend
|
|
120
|
+
if isinstance(backend, _UndecidedBackend):
|
|
121
|
+
backend = backend._resolved
|
|
122
|
+
if backend is None:
|
|
123
|
+
return # nothing has been read yet
|
|
124
|
+
backend.refresh()
|
|
125
|
+
|
|
126
|
+
def as_uri(self) -> str:
|
|
127
|
+
# Encoded so the string parses back to the same member: the member
|
|
128
|
+
# path's `%`, `?` and `#`, and a literal "!/" inside the archive URI.
|
|
129
|
+
outer = self.archive
|
|
130
|
+
outer_uri = outer.as_uri()
|
|
131
|
+
if not isinstance(outer, ArchivePath):
|
|
132
|
+
# A nested archive's outer keeps its own separator (it already
|
|
133
|
+
# encodes any other "!/").
|
|
134
|
+
outer_uri = outer_uri.replace(_SEP, "%21/")
|
|
135
|
+
member = _urlparse.quote(self.as_posix().lstrip("/"), safe="/")
|
|
136
|
+
# A "!/" inside a member name must never read as a separator once
|
|
137
|
+
# this URI is itself the outer of a nested archive.
|
|
138
|
+
member = member.replace(_SEP, "%21/")
|
|
139
|
+
return f"{self._SCHEME}:{outer_uri}{_SEP}{member}"
|
|
140
|
+
|
|
141
|
+
def _snapshot(self) -> _Snapshot:
|
|
142
|
+
"""The archive's members as of one validation of its handle; see
|
|
143
|
+
`_Snapshot`."""
|
|
144
|
+
return self.backend.snapshot()
|
|
145
|
+
|
|
146
|
+
def _raw_name(self, name: str) -> str:
|
|
147
|
+
"""The backend's own key for a normalized name (the name itself when
|
|
148
|
+
the archive spells it canonically, or holds no such member yet).
|
|
149
|
+
|
|
150
|
+
A write may be the call that CREATES the archive, so a missing outer
|
|
151
|
+
is not an error here -- there is simply nothing to map yet.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
return self._snapshot().index.get(name, name)
|
|
155
|
+
except OSError:
|
|
156
|
+
return name
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def _member(self) -> "str | None":
|
|
160
|
+
"""This path as a normalized member name, or None if it escapes."""
|
|
161
|
+
return _normalize_member_name(self.as_posix().removeprefix("/"))
|
|
162
|
+
|
|
163
|
+
def _resolve(self, snapshot: _Snapshot, member: str, follow: bool = True) -> str:
|
|
164
|
+
"""`member` with the link members on its way followed (the last
|
|
165
|
+
component only when `follow`), or the error a lookup of it raises:
|
|
166
|
+
`FileNotFoundError` naming the link that leaves the archive, is
|
|
167
|
+
absolute or has no text, `OSError(ELOOP)` after `MAX_LINKS` links."""
|
|
168
|
+
try:
|
|
169
|
+
return snapshot.resolve(member, follow=follow)
|
|
170
|
+
except _Unresolved as error:
|
|
171
|
+
link = self._rebuild(error.link)
|
|
172
|
+
if error.loop:
|
|
173
|
+
raise OSError(
|
|
174
|
+
_errno.ELOOP, "Too many levels of symbolic links", str(link)
|
|
175
|
+
) from None
|
|
176
|
+
raise FileNotFoundError(
|
|
177
|
+
_errno.ENOENT,
|
|
178
|
+
"A link does not resolve inside the archive",
|
|
179
|
+
str(link),
|
|
180
|
+
) from None
|
|
181
|
+
|
|
182
|
+
def readlink(self) -> LinkTarget:
|
|
183
|
+
"""The text a link member stores, as a `LinkTarget` (`OSError(EINVAL)`
|
|
184
|
+
when this member is not a link). Links on the way to it are followed."""
|
|
185
|
+
member = self._member
|
|
186
|
+
if member is None:
|
|
187
|
+
raise FileNotFoundError(str(self))
|
|
188
|
+
snapshot = self._snapshot()
|
|
189
|
+
member = self._resolve(snapshot, member, follow=False)
|
|
190
|
+
if snapshot.stat(member) is None:
|
|
191
|
+
raise FileNotFoundError(str(self))
|
|
192
|
+
if member not in snapshot.links:
|
|
193
|
+
raise OSError(_errno.EINVAL, "Invalid argument", str(self))
|
|
194
|
+
text = snapshot.links[member]
|
|
195
|
+
if text is None:
|
|
196
|
+
raise OSError(_errno.EIO, "The link's text cannot be read", str(self))
|
|
197
|
+
target = LinkTarget(text)
|
|
198
|
+
target._text = text
|
|
199
|
+
return target
|
|
200
|
+
|
|
201
|
+
def _directory_children(self) -> "tuple[_Snapshot, str, list[str]]":
|
|
202
|
+
member = self._member
|
|
203
|
+
if member is None:
|
|
204
|
+
raise FileNotFoundError(str(self))
|
|
205
|
+
snapshot = self._snapshot()
|
|
206
|
+
member = self._resolve(snapshot, member)
|
|
207
|
+
found = snapshot.stat(member)
|
|
208
|
+
if found is None:
|
|
209
|
+
raise FileNotFoundError(str(self))
|
|
210
|
+
if not found.is_dir():
|
|
211
|
+
raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
|
|
212
|
+
return snapshot, member, list(snapshot.children.get(member, ()))
|
|
213
|
+
|
|
214
|
+
def _scandir(self):
|
|
215
|
+
# The stat data is in the snapshot already: a listing costs one
|
|
216
|
+
# validation of the handle, however many entries it has.
|
|
217
|
+
snapshot, member, names = self._directory_children()
|
|
218
|
+
prefix = f"{member}/" if member else ""
|
|
219
|
+
for name in names:
|
|
220
|
+
if not is_safe_child_name(name):
|
|
221
|
+
continue
|
|
222
|
+
found = snapshot.stat(prefix + name)
|
|
223
|
+
if found is not None:
|
|
224
|
+
yield name, found
|
|
225
|
+
|
|
226
|
+
def iterdir(self):
|
|
227
|
+
for name, _stat in self._scandir():
|
|
228
|
+
yield self.with_segments(*self.segments, name)
|
|
229
|
+
|
|
230
|
+
def stat(self, *, follow_symlinks=True):
|
|
231
|
+
member = self._member
|
|
232
|
+
if member is None:
|
|
233
|
+
raise FileNotFoundError(str(self))
|
|
234
|
+
# The root too: a missing or unreadable archive is not a directory
|
|
235
|
+
# that exists, and a listing of it fails the same way.
|
|
236
|
+
snapshot = self._snapshot()
|
|
237
|
+
resolved = self._resolve(snapshot, member, follow_symlinks)
|
|
238
|
+
if follow_symlinks and snapshot.links:
|
|
239
|
+
self._check_enterable(snapshot, member, resolved)
|
|
240
|
+
found = snapshot.stat(resolved)
|
|
241
|
+
if found is None:
|
|
242
|
+
raise FileNotFoundError(str(self))
|
|
243
|
+
return found
|
|
244
|
+
|
|
245
|
+
def _check_enterable(self, snapshot: _Snapshot, member: str, resolved: str) -> None:
|
|
246
|
+
"""A walk that follows links asks each listed child for its following
|
|
247
|
+
stat, so that answer decides whether it descends. A link to a
|
|
248
|
+
directory that lies beyond another link, or that points at the
|
|
249
|
+
directory holding it or one of that directory's ancestors, answers
|
|
250
|
+
`OSError(ELOOP)`: it is a link and not a directory to every walker,
|
|
251
|
+
and the entries a recursive operation visits stay bounded by the
|
|
252
|
+
members, the links and the directory links, however the links point.
|
|
253
|
+
A path spelled through further links is still read, listed and
|
|
254
|
+
opened."""
|
|
255
|
+
entry = snapshot.resolve(member, follow=False)
|
|
256
|
+
if entry not in snapshot.links:
|
|
257
|
+
return
|
|
258
|
+
found = snapshot.stat(resolved)
|
|
259
|
+
if found is None or not found.is_dir():
|
|
260
|
+
return
|
|
261
|
+
names = member.split("/")
|
|
262
|
+
beyond = any(
|
|
263
|
+
"/".join(names[:count]) in snapshot.links for count in range(1, len(names))
|
|
264
|
+
)
|
|
265
|
+
parent = entry.rpartition("/")[0]
|
|
266
|
+
around = (
|
|
267
|
+
resolved == "" or resolved == parent or parent.startswith(resolved + "/")
|
|
268
|
+
)
|
|
269
|
+
if beyond or around:
|
|
270
|
+
raise OSError(
|
|
271
|
+
_errno.ELOOP,
|
|
272
|
+
"A link to a directory is not entered "
|
|
273
|
+
+ (
|
|
274
|
+
"beyond another link"
|
|
275
|
+
if beyond
|
|
276
|
+
else "when it points at the directory that holds it or above"
|
|
277
|
+
),
|
|
278
|
+
str(self),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _is_dir_member(self) -> bool:
|
|
282
|
+
try:
|
|
283
|
+
return self.stat().is_dir()
|
|
284
|
+
except FileNotFoundError:
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
def _read_member(self):
|
|
288
|
+
member = self._member
|
|
289
|
+
if member is None:
|
|
290
|
+
raise FileNotFoundError(str(self))
|
|
291
|
+
try:
|
|
292
|
+
snapshot = self._snapshot()
|
|
293
|
+
spelled, member = member, self._resolve(snapshot, member)
|
|
294
|
+
if snapshot.links:
|
|
295
|
+
self._check_enterable(snapshot, spelled, member)
|
|
296
|
+
if snapshot.hidden(member):
|
|
297
|
+
raise KeyError(member)
|
|
298
|
+
return self.backend.read_member(self._raw_name(member))
|
|
299
|
+
except KeyError as error:
|
|
300
|
+
if self._is_dir_member():
|
|
301
|
+
raise IsADirectoryError(
|
|
302
|
+
_errno.EISDIR, "Is a directory", str(self)
|
|
303
|
+
) from error
|
|
304
|
+
raise FileNotFoundError(str(self)) from error
|
|
305
|
+
|
|
306
|
+
def _open(self, mode="r", buffering=-1):
|
|
307
|
+
if "r" in mode and "+" not in mode:
|
|
308
|
+
return self._read_member()
|
|
309
|
+
return self._open_for_write(mode)
|
|
310
|
+
|
|
311
|
+
def _node_key(self):
|
|
312
|
+
# One member per archive handle, whichever spelling names it.
|
|
313
|
+
member = self._member
|
|
314
|
+
if member is None:
|
|
315
|
+
return None
|
|
316
|
+
return self.backend, tuple(member.split("/")) if member else ()
|
|
317
|
+
|
|
318
|
+
def _same_filesystem(self, other: "ArchivePath") -> bool:
|
|
319
|
+
# The archive handle is the namespace. A path of an undecided archive
|
|
320
|
+
# holds a stand-in until its format is settled, and two of them naming
|
|
321
|
+
# one archive settle on one handle.
|
|
322
|
+
try:
|
|
323
|
+
return other.backend is self.backend
|
|
324
|
+
except Exception:
|
|
325
|
+
# The archive cannot be read: nothing settles its handle.
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class ZipPath(ArchivePath):
|
|
330
|
+
"""A member path of a zip archive, whatever its extension. Read/write when
|
|
331
|
+
the archive file is a local `Path`; every other archive is read-only (it
|
|
332
|
+
is fetched whole into memory first). Every change replaces the archive
|
|
333
|
+
atomically (a temporary file next to it, then `os.replace`), so the result
|
|
334
|
+
is a new file: other hard links keep the old content, and an archive the
|
|
335
|
+
caller may not write is refused first. A new member is appended, deflated,
|
|
336
|
+
to a byte copy of the archive (nothing recompressed); overwriting,
|
|
337
|
+
deleting or renaming a member rewrites the archive, keeping every other
|
|
338
|
+
member's metadata and compression method."""
|
|
339
|
+
|
|
340
|
+
__slots__ = ()
|
|
341
|
+
_SCHEME = "zip"
|
|
342
|
+
_backend_cls = _ZipBackend
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class TarPath(ArchivePath):
|
|
346
|
+
"""A member path of a tar archive, plain or compressed (`tarfile`'s
|
|
347
|
+
detected `r:*` mode), whatever its extension. Read-only. Members stored
|
|
348
|
+
with a `./` prefix are addressed without it."""
|
|
349
|
+
|
|
350
|
+
__slots__ = ()
|
|
351
|
+
_SCHEME = "tar"
|
|
352
|
+
_backend_cls = _TarBackend
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib as _pathlib
|
|
4
|
+
|
|
5
|
+
from .._filestat import FileStat
|
|
6
|
+
from ._names import _normalize_member_name
|
|
7
|
+
|
|
8
|
+
MAX_LINKS = 40
|
|
9
|
+
"""The most link members one lookup follows before it is a loop
|
|
10
|
+
(`OSError(ELOOP)`)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LinkTarget(_pathlib.PurePosixPath):
|
|
14
|
+
"""What `readlink()` of a link member returns: the stored text as a
|
|
15
|
+
`PurePosixPath`, which spells it normalized (`./a//b` is `a/b`), and as
|
|
16
|
+
`path`, which is the text exactly as stored. `PathSyncer` and `copy()`
|
|
17
|
+
recreate a link from `path`."""
|
|
18
|
+
|
|
19
|
+
__slots__ = ("_text",)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def path(self) -> str:
|
|
23
|
+
try:
|
|
24
|
+
return self._text
|
|
25
|
+
except AttributeError:
|
|
26
|
+
return self.as_posix()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
NO_LINK = object()
|
|
30
|
+
"""What a snapshot is given for an entry that is not a link."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Unresolved(Exception):
|
|
34
|
+
"""A lookup that cannot get through the link members on its way: the one
|
|
35
|
+
named `link` leaves the archive, is absolute or has no readable text
|
|
36
|
+
(`loop` False), or the lookup followed `MAX_LINKS` links (`loop` True)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, link: str, loop: bool):
|
|
39
|
+
super().__init__(link)
|
|
40
|
+
self.link = link
|
|
41
|
+
self.loop = loop
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Snapshot:
|
|
45
|
+
"""What one open handle holds, derived once from its entries and kept
|
|
46
|
+
until the handle is dropped: the normalized-name index, every raw
|
|
47
|
+
spelling of a name, the directories (implicit ones included) with their
|
|
48
|
+
children, and the stat data of each entry. Nothing here touches the
|
|
49
|
+
handle again, so a lookup or a listing sees one consistent archive
|
|
50
|
+
however the file changes meanwhile.
|
|
51
|
+
|
|
52
|
+
A member whose name escapes the root is left out entirely, so it can be
|
|
53
|
+
neither listed nor looked up. When two spellings normalize to one name
|
|
54
|
+
the later entry wins, as `zipfile`/`tarfile` resolve duplicates;
|
|
55
|
+
`spellings` lists all of them."""
|
|
56
|
+
|
|
57
|
+
__slots__ = (
|
|
58
|
+
"index",
|
|
59
|
+
"spellings",
|
|
60
|
+
"infos",
|
|
61
|
+
"files",
|
|
62
|
+
"implicit",
|
|
63
|
+
"children",
|
|
64
|
+
"links",
|
|
65
|
+
"_format",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def __init__(self, format: "type[_ArchiveBackend]", entries, links=None):
|
|
69
|
+
# The backend class, not the backend: a snapshot that held its backend
|
|
70
|
+
# would keep it alive in a reference cycle.
|
|
71
|
+
self._format = format
|
|
72
|
+
index: "dict[str, str]" = {}
|
|
73
|
+
spellings: "dict[str, list[str]]" = {}
|
|
74
|
+
infos: "dict[str, object]" = {}
|
|
75
|
+
for raw, info in entries:
|
|
76
|
+
infos[raw] = info
|
|
77
|
+
normalized = _normalize_member_name(raw)
|
|
78
|
+
if not normalized:
|
|
79
|
+
continue
|
|
80
|
+
previous = index.get(normalized)
|
|
81
|
+
if previous is not None and previous != raw:
|
|
82
|
+
others = spellings.setdefault(normalized, [previous])
|
|
83
|
+
if raw not in others:
|
|
84
|
+
others.append(raw)
|
|
85
|
+
index[normalized] = raw
|
|
86
|
+
self.index = index
|
|
87
|
+
self.spellings = spellings
|
|
88
|
+
self.infos = infos
|
|
89
|
+
# The link members, by normalized name: the text they store, or None
|
|
90
|
+
# when it cannot be read. `links` maps raw names.
|
|
91
|
+
self.links = (
|
|
92
|
+
{name: links[raw] for name, raw in index.items() if raw in links}
|
|
93
|
+
if links
|
|
94
|
+
else {}
|
|
95
|
+
)
|
|
96
|
+
# Members that are files: nothing lies below one. A link is not one:
|
|
97
|
+
# what lies below it lies below what it points at.
|
|
98
|
+
self.files = {
|
|
99
|
+
name
|
|
100
|
+
for name, raw in index.items()
|
|
101
|
+
if not name.endswith("/")
|
|
102
|
+
and not format._is_directory(infos[raw])
|
|
103
|
+
and name not in self.links
|
|
104
|
+
}
|
|
105
|
+
# Every proper prefix of a name is a directory, and `children` holds
|
|
106
|
+
# the first component below each directory, in archive order.
|
|
107
|
+
implicit: "set[str]" = set()
|
|
108
|
+
children: "dict[str, dict[str, None]]" = {"": {}}
|
|
109
|
+
for name, raw in index.items():
|
|
110
|
+
parts = name.rstrip("/").split("/")
|
|
111
|
+
directory = ""
|
|
112
|
+
for position, part in enumerate(parts):
|
|
113
|
+
children.setdefault(directory, {})[part] = None
|
|
114
|
+
path = f"{directory}/{part}" if directory else part
|
|
115
|
+
if position < len(parts) - 1:
|
|
116
|
+
implicit.add(path)
|
|
117
|
+
directory = path
|
|
118
|
+
if name.endswith("/") or format._is_directory(infos[raw]):
|
|
119
|
+
children.setdefault(directory, {})
|
|
120
|
+
self.implicit = implicit
|
|
121
|
+
self.children = children
|
|
122
|
+
|
|
123
|
+
def add(self, raw: str, info, link=NO_LINK) -> bool:
|
|
124
|
+
"""Adds the entry `raw`, or replaces its info, in place, giving the
|
|
125
|
+
snapshot the entries it would have been built from with this one
|
|
126
|
+
last. `link` is the text of a link entry (None when unreadable). False,
|
|
127
|
+
and nothing changed, when `raw` names a member the snapshot already
|
|
128
|
+
holds under another spelling. For a snapshot that no other thread
|
|
129
|
+
reads."""
|
|
130
|
+
normalized = _normalize_member_name(raw)
|
|
131
|
+
current = self.index.get(normalized) if normalized else None
|
|
132
|
+
if current is not None and current != raw:
|
|
133
|
+
return False
|
|
134
|
+
self.infos[raw] = info
|
|
135
|
+
if not normalized:
|
|
136
|
+
return True
|
|
137
|
+
self.index[normalized] = raw
|
|
138
|
+
if link is NO_LINK:
|
|
139
|
+
self.links.pop(normalized, None)
|
|
140
|
+
else:
|
|
141
|
+
self.links[normalized] = link
|
|
142
|
+
directory = normalized.endswith("/") or self._format._is_directory(info)
|
|
143
|
+
if directory or link is not NO_LINK:
|
|
144
|
+
self.files.discard(normalized)
|
|
145
|
+
else:
|
|
146
|
+
self.files.add(normalized)
|
|
147
|
+
if current is not None:
|
|
148
|
+
return True
|
|
149
|
+
directory_path = ""
|
|
150
|
+
parts = normalized.rstrip("/").split("/")
|
|
151
|
+
for position, part in enumerate(parts):
|
|
152
|
+
self.children.setdefault(directory_path, {})[part] = None
|
|
153
|
+
path = f"{directory_path}/{part}" if directory_path else part
|
|
154
|
+
if position < len(parts) - 1:
|
|
155
|
+
self.implicit.add(path)
|
|
156
|
+
directory_path = path
|
|
157
|
+
if directory:
|
|
158
|
+
self.children.setdefault(directory_path, {})
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
def drop(self, raw: str) -> bool:
|
|
162
|
+
"""Removes the entry `raw` in place, giving the snapshot the entries it
|
|
163
|
+
would have been built from without it: a directory that existed only
|
|
164
|
+
through it goes too, and so does each directory above that was left
|
|
165
|
+
with nothing. A directory keeps its place in the listing of its parent.
|
|
166
|
+
False, and nothing changed, when its name has several spellings here.
|
|
167
|
+
For a snapshot that no other thread reads."""
|
|
168
|
+
normalized = _normalize_member_name(raw)
|
|
169
|
+
if normalized and (
|
|
170
|
+
self.index.get(normalized) != raw or normalized in self.spellings
|
|
171
|
+
):
|
|
172
|
+
return False
|
|
173
|
+
self.infos.pop(raw, None)
|
|
174
|
+
if not normalized:
|
|
175
|
+
return True
|
|
176
|
+
del self.index[normalized]
|
|
177
|
+
self.files.discard(normalized)
|
|
178
|
+
self.links.pop(normalized, None)
|
|
179
|
+
path = normalized.rstrip("/")
|
|
180
|
+
while path:
|
|
181
|
+
below = self.children.get(path)
|
|
182
|
+
if not below:
|
|
183
|
+
self.implicit.discard(path)
|
|
184
|
+
if path in self.index or f"{path}/" in self.index or below:
|
|
185
|
+
break
|
|
186
|
+
parent, _, leaf = path.rpartition("/")
|
|
187
|
+
self.children.pop(path, None)
|
|
188
|
+
siblings = self.children.get(parent)
|
|
189
|
+
if siblings is not None:
|
|
190
|
+
siblings.pop(leaf, None)
|
|
191
|
+
path = parent
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def resolve(self, member: str, *, follow: bool = True) -> str:
|
|
195
|
+
"""`member` with every link member on its way replaced by what it
|
|
196
|
+
stores, as a normalized name that is not a link: a relative text is
|
|
197
|
+
read from the directory that holds the link, an absolute one or one
|
|
198
|
+
that leaves the root names nothing. The last component is left alone
|
|
199
|
+
unless `follow`. Raises `_Unresolved`. Costs nothing when the archive
|
|
200
|
+
holds no link."""
|
|
201
|
+
links = self.links
|
|
202
|
+
if not links:
|
|
203
|
+
return member
|
|
204
|
+
done: "list[str]" = []
|
|
205
|
+
todo = member.split("/")[::-1] if member else []
|
|
206
|
+
followed = 0
|
|
207
|
+
last = member
|
|
208
|
+
while todo:
|
|
209
|
+
part = todo.pop()
|
|
210
|
+
if part in ("", "."):
|
|
211
|
+
continue
|
|
212
|
+
if part == "..":
|
|
213
|
+
if not done:
|
|
214
|
+
raise _Unresolved(last, False)
|
|
215
|
+
done.pop()
|
|
216
|
+
continue
|
|
217
|
+
name = "/".join((*done, part))
|
|
218
|
+
if name in links and (todo or follow):
|
|
219
|
+
followed += 1
|
|
220
|
+
last = name
|
|
221
|
+
if followed > MAX_LINKS:
|
|
222
|
+
raise _Unresolved(name, True)
|
|
223
|
+
text = links[name]
|
|
224
|
+
if not text or text.startswith("/"):
|
|
225
|
+
raise _Unresolved(name, False)
|
|
226
|
+
todo.extend(text.split("/")[::-1])
|
|
227
|
+
continue
|
|
228
|
+
done.append(part)
|
|
229
|
+
return "/".join(done)
|
|
230
|
+
|
|
231
|
+
def hidden(self, member: str) -> bool:
|
|
232
|
+
"""Whether a file member sits above `member`. A file has no children:
|
|
233
|
+
`y/z` next to a file `y` does not exist, as for a name that escapes
|
|
234
|
+
the root, so a lookup agrees with the listing that cannot show it."""
|
|
235
|
+
files = self.files
|
|
236
|
+
start = member.find("/")
|
|
237
|
+
while start > 0:
|
|
238
|
+
if member[:start] in files:
|
|
239
|
+
return True
|
|
240
|
+
start = member.find("/", start + 1)
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
def stat(self, member: str) -> "FileStat | None":
|
|
244
|
+
"""The stat of the member `member` (normalized, no trailing slash
|
|
245
|
+
unless it names a directory entry), or None when there is none. The
|
|
246
|
+
root is a directory; a directory that exists only through its members
|
|
247
|
+
is one too."""
|
|
248
|
+
if member == "":
|
|
249
|
+
return FileStat(is_dir=True)
|
|
250
|
+
if self.hidden(member):
|
|
251
|
+
return None
|
|
252
|
+
raw = self.index.get(member)
|
|
253
|
+
if raw is None:
|
|
254
|
+
raw = self.index.get(f"{member}/")
|
|
255
|
+
if raw is not None:
|
|
256
|
+
return self._format._stat_of(self.infos[raw], self)
|
|
257
|
+
if member in self.implicit:
|
|
258
|
+
return FileStat(is_dir=True)
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def raw_names(self, normalized: str) -> "tuple[str, ...]":
|
|
262
|
+
"""Every key the archive holds the member `normalized` under, in
|
|
263
|
+
archive order (empty when there is none): an archive can spell one
|
|
264
|
+
member `norm` and `./norm`, and removing or renaming it has to act on
|
|
265
|
+
all of them or the shadowed one comes back."""
|
|
266
|
+
raw = self.index.get(normalized)
|
|
267
|
+
if raw is None:
|
|
268
|
+
return ()
|
|
269
|
+
return tuple(self.spellings.get(normalized) or (raw,))
|