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
pathapi/_tree.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import typing as _ty
4
+
5
+ from .path import Path, Pathname
6
+
7
+ __all__ = ["TreePath"]
8
+
9
+
10
+ class TreePath(Path):
11
+ """A `Path` whose names are POSIX segments below the root of a tree that a
12
+ backend serves (a remote directory, the members of an archive).
13
+
14
+ Joined and normalized like `PurePosixPath`: empty and "." segments
15
+ collapse and an absolute argument restarts the join. Relative and rooted
16
+ spellings name the same node, because the tree has no working directory.
17
+ A subclass supplies the constructor, `_rebuild()` and the backend it
18
+ carries; this class holds the names only."""
19
+
20
+ __slots__ = ("_segments",)
21
+
22
+ @classmethod
23
+ def _family(cls) -> type:
24
+ """The class directly under `TreePath` that `cls` derives from: its
25
+ instances can donate a backend to a join."""
26
+ return next(c for c in cls.__mro__ if TreePath in c.__bases__)
27
+
28
+ @classmethod
29
+ def _join(cls, segments: "_ty.Iterable[object]") -> "tuple[str, TreePath | None]":
30
+ """`(text, donor)`: the joined text of `segments` and the first of them
31
+ that belongs to this class's family, whose backend the result keeps.
32
+ `NotImplementedError` for another `Path` (so `/` answers
33
+ `NotImplemented`), `TypeError` for what is not a path at all."""
34
+ path = ""
35
+ donor = None
36
+ family = cls._family()
37
+ for segment in segments:
38
+ if isinstance(segment, family):
39
+ text = segment.as_posix()
40
+ if donor is None:
41
+ donor = segment
42
+ elif isinstance(segment, Path):
43
+ raise NotImplementedError()
44
+ elif isinstance(segment, Pathname):
45
+ text = "/".join(segment.segments)
46
+ elif isinstance(segment, str):
47
+ text = segment
48
+ else:
49
+ raise TypeError(
50
+ "argument should be a str or a Pathname, "
51
+ f"not {type(segment).__name__!r}"
52
+ )
53
+ if text.startswith("/") or not path:
54
+ path = text
55
+ elif text:
56
+ path = f"{path}/{text}"
57
+ return path, donor
58
+
59
+ @staticmethod
60
+ def _parse(path: str) -> tuple:
61
+ """Segments of a joined path string: `("", "")` for the root,
62
+ `("", name, ...)` for another rooted path, `(name, ...)` for a
63
+ relative one and `()` for the empty path."""
64
+ names = [name for name in path.split("/") if name and name != "."]
65
+ if path.startswith("/"):
66
+ return ("", *names) if names else ("", "")
67
+ return tuple(names)
68
+
69
+ def _rebuild(self, text: str) -> _ty.Self:
70
+ """A path of this class and backend for the joined text `text`."""
71
+ raise NotImplementedError
72
+
73
+ def __str__(self) -> str:
74
+ return self.as_posix()
75
+
76
+ def __copy__(self):
77
+ return self._rebuild(self.as_posix())
78
+
79
+ def __deepcopy__(self, memo):
80
+ # A backend is a connection or a handle, not part of the value.
81
+ inst = memo[id(self)] = self.__copy__()
82
+ return inst
83
+
84
+ @property
85
+ def segments(self):
86
+ return self._segments
87
+
88
+ @property
89
+ def parts(self):
90
+ return self.segments, self._backend
91
+
92
+ @property
93
+ def parent(self):
94
+ segments = self.segments
95
+ if not segments or segments == ("", ""):
96
+ return self
97
+ if len(segments) == 2 and segments[0] == "":
98
+ return self._rebuild("/")
99
+ return self._rebuild("/".join(segments[:-1]))
100
+
101
+ def _cwd(self):
102
+ return self._rebuild("/")
103
+
104
+ def with_segments(self, *segments: str):
105
+ return self._rebuild("/".join(segments))
106
+
107
+ def relative_to(self, other, *, walk_up: bool = False):
108
+ """`self` below `other`, as a relative path on the same backend.
109
+
110
+ `other` is a path of this class on the same tree or a `str`. Names are
111
+ compared as written, with no `..` applied. `ValueError` when `self`
112
+ is not below `other`, when the two differ in being rooted, or when
113
+ the trees differ. With `walk_up`, a path beside `other` gets a `..`
114
+ per level climbed; a `..` in `other` cannot be walked."""
115
+ if isinstance(other, self._family()):
116
+ if not self._same_filesystem(other):
117
+ raise ValueError(
118
+ f"{str(self)!r} and {str(other)!r} are not in the same tree"
119
+ )
120
+ else:
121
+ other = self.with_segments(other)
122
+ rooted = self.segments[:1] == ("",)
123
+ other_rooted = other.segments[:1] == ("",)
124
+ names = [name for name in self.segments if name]
125
+ other_names = [name for name in other.segments if name]
126
+ for step in range(len(other_names) + 1):
127
+ keep = len(other_names) - step
128
+ if rooted == other_rooted and names[:keep] == other_names[:keep]:
129
+ return self.with_segments(*[".."] * step, *names[keep:])
130
+ if not walk_up:
131
+ raise ValueError(
132
+ f"{str(self)!r} is not in the subpath of {str(other)!r}"
133
+ )
134
+ if keep and other_names[keep - 1] == "..":
135
+ raise ValueError(f"'..' segment in {str(other)!r} cannot be walked")
136
+ raise ValueError(f"{str(self)!r} and {str(other)!r} have different anchors")
@@ -0,0 +1,44 @@
1
+ """Zip and tar archives as trees of paths, on the standard library alone
2
+ (`zipfile`, `tarfile`).
3
+
4
+ `ArchivePath`, `ZipPath` and `TarPath` are `Path`s for the members of an
5
+ archive file, which is any `Path`; they need no URI.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys as _sys
11
+ import types as _types
12
+
13
+ from . import _backend
14
+ from ._pack import detect_format, make_archive, unpack_archive
15
+ from ._path import ArchivePath, TarPath, ZipPath
16
+ from ._snapshot import MAX_LINKS, LinkTarget
17
+
18
+ __all__ = [
19
+ "ArchivePath",
20
+ "LinkTarget",
21
+ "MAX_LINKS",
22
+ "MEMBER_SPOOL_BYTES",
23
+ "TarPath",
24
+ "ZipPath",
25
+ "detect_format",
26
+ "make_archive",
27
+ "unpack_archive",
28
+ ]
29
+
30
+
31
+ class _Module(_types.ModuleType):
32
+ """The module, with `MEMBER_SPOOL_BYTES` (the size past which a member read
33
+ goes to a temporary file) read from and written to where it takes effect."""
34
+
35
+ @property
36
+ def MEMBER_SPOOL_BYTES(self) -> int:
37
+ return _backend.MEMBER_SPOOL_BYTES
38
+
39
+ @MEMBER_SPOOL_BYTES.setter
40
+ def MEMBER_SPOOL_BYTES(self, value: int) -> None:
41
+ _backend.MEMBER_SPOOL_BYTES = value
42
+
43
+
44
+ _sys.modules[__name__].__class__ = _Module
@@ -0,0 +1,393 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib as _contextlib
4
+ import io as _io
5
+ import os as _os
6
+ import shutil as _shutil
7
+ import tempfile as _tempfile
8
+ import threading as _threading
9
+ import weakref as _weakref
10
+
11
+ from ..path import Path
12
+ from ..protocols._relation import _local_file
13
+ from .._filestat import FileStat
14
+ from ._snapshot import _Snapshot
15
+
16
+ _registry_lock = _threading.Lock()
17
+ _registry: "_weakref.WeakValueDictionary[tuple[type, object], _ArchiveBackend]" = (
18
+ _weakref.WeakValueDictionary()
19
+ )
20
+
21
+
22
+ def _local_outer_path(outer: "Path") -> "str | None":
23
+ """The local filesystem path of an outer archive that is a file of this
24
+ machine (a `LocalPath`, or a path holding one such as a `file:` URI), else
25
+ None."""
26
+ local = _local_file(outer)
27
+ if local is None:
28
+ return None
29
+ try:
30
+ return str(local)
31
+ except (NotImplementedError, ValueError):
32
+ return None
33
+
34
+
35
+ def _outer_identity(outer: "Path") -> object:
36
+ """What names a non-local outer archive in the registry: its node, for a
37
+ path that answers `_node_key()`, else its URI."""
38
+ try:
39
+ node = outer._node_key()
40
+ except Exception:
41
+ node = None
42
+ if node is not None:
43
+ space, names = node
44
+ return id(space), names
45
+ return outer.as_uri()
46
+
47
+
48
+ def _registry_key(
49
+ backend_cls: type, outer: "Path", identity: object = None
50
+ ) -> "tuple[type, object]":
51
+ local = _local_outer_path(outer)
52
+ if local is not None:
53
+ # One key per file, not per spelling: `C:` vs `c:` or a symlink used
54
+ # to get independent handles onto the same file.
55
+ return backend_cls, _os.path.normcase(_os.path.realpath(local))
56
+ return backend_cls, _outer_identity(outer) if identity is None else identity
57
+
58
+
59
+ def _get_backend(
60
+ backend_cls: "type[_ArchiveBackend]", outer: "Path", identity: object = None
61
+ ) -> "_ArchiveBackend":
62
+ """Return the shared `_ArchiveBackend` for `outer`, creating one if this
63
+ is the first live reference. Keyed by (backend class, canonical local
64
+ file path -- or `identity`, else the node or URI of a non-local outer) so
65
+ independently-constructed paths pointing at the same archive share one
66
+ handle instead of each opening their own -- avoids the stale-read/
67
+ corrupt-write hazard of two handles writing the same underlying file.
68
+ Backed by a `WeakValueDictionary`: once every path referencing a given
69
+ backend is garbage-collected, the backend itself is collected (its
70
+ `__del__` closes the underlying handle) and the registry entry is
71
+ dropped automatically -- no explicit refcounting needed."""
72
+ key = _registry_key(backend_cls, outer, identity)
73
+ with _registry_lock:
74
+ backend = _registry.get(key)
75
+ if backend is None:
76
+ backend = backend_cls(outer)
77
+ _registry[key] = backend
78
+ return backend
79
+
80
+
81
+ MEMBER_SPOOL_BYTES = 16 * 1024 * 1024
82
+ """A member read through an archive path is held in memory up to this many
83
+ bytes and spooled to an unnamed temporary file beyond that, so a member
84
+ larger than memory can be read."""
85
+
86
+ _CHUNK = 64 * 1024
87
+
88
+
89
+ def _spool(source) -> "_io.IOBase":
90
+ """A seekable copy of the stream `source`, in memory up to
91
+ `MEMBER_SPOOL_BYTES` and in a temporary file past it. The copy is what a
92
+ caller keeps, so the archive's shared handle is not tied up by a reader
93
+ that keeps its stream open across later changes to the archive."""
94
+ memory = _io.BytesIO()
95
+ while True:
96
+ chunk = source.read(_CHUNK)
97
+ if not chunk:
98
+ memory.seek(0)
99
+ return memory
100
+ memory.write(chunk)
101
+ if memory.tell() > MEMBER_SPOOL_BYTES:
102
+ break
103
+ disk = _tempfile.TemporaryFile()
104
+ try:
105
+ with memory.getbuffer() as written:
106
+ disk.write(written)
107
+ memory.close()
108
+ _shutil.copyfileobj(source, disk, _CHUNK)
109
+ disk.seek(0)
110
+ except BaseException:
111
+ disk.close()
112
+ raise
113
+ return disk
114
+
115
+
116
+ class _LazyReadFile:
117
+ """A read-only, seekable view of a local file that holds an OS handle
118
+ only while an operation runs: `release()` closes it, and the next
119
+ read/seek reopens it at the same position. A shared `ZipFile` or
120
+ `TarFile` over one therefore never keeps the archive open between
121
+ operations (on Windows an open handle blocks deleting or replacing the
122
+ file)."""
123
+
124
+ def __init__(self, path: str):
125
+ self.name = path
126
+ self._pos = 0
127
+ # Opened eagerly: a missing file raises FileNotFoundError here,
128
+ # which `zipfile` would otherwise turn into BadZipFile.
129
+ self._fp = open(path, "rb")
130
+
131
+ def _file(self):
132
+ if self._fp is None:
133
+ self._fp = open(self.name, "rb")
134
+ self._fp.seek(self._pos)
135
+ return self._fp
136
+
137
+ def read(self, size=-1):
138
+ data = self._file().read(size)
139
+ self._pos = self._fp.tell()
140
+ return data
141
+
142
+ def seek(self, offset, whence=0):
143
+ self._pos = self._file().seek(offset, whence)
144
+ return self._pos
145
+
146
+ def tell(self):
147
+ return self._pos
148
+
149
+ def seekable(self):
150
+ return True
151
+
152
+ def readable(self):
153
+ return True
154
+
155
+ def release(self):
156
+ fp, self._fp = self._fp, None
157
+ if fp is not None:
158
+ fp.close()
159
+
160
+ close = release
161
+
162
+
163
+ class _ArchiveBackend:
164
+ """Lazily opens+caches the archive handle for one outer archive.
165
+ Shared by every archive path derived from the same one, both through
166
+ normal backend propagation (`with_segments`/`joinpath`/...) AND across
167
+ independently-constructed paths via the module-level `_get_backend`
168
+ registry above.
169
+
170
+ For a local outer the cached handle is revalidated against the file's
171
+ (inode, size, mtime) on every access and reopened when it changed, so a
172
+ write by another process or tool is seen instead of being overwritten
173
+ from a stale central directory. `_lock` serializes every use of the
174
+ shared handle."""
175
+
176
+ __slots__ = (
177
+ "outer",
178
+ "_handle",
179
+ "_signature",
180
+ "_lock",
181
+ "_snapshot",
182
+ "_prefetched",
183
+ "__weakref__",
184
+ )
185
+
186
+ #: What a damaged archive raises, whatever decoder noticed it.
187
+ _FORMAT_ERROR: "type[Exception]" = OSError
188
+
189
+ def __init__(self, outer: "Path"):
190
+ self.outer = outer
191
+ self._handle = None
192
+ self._signature = None
193
+ self._snapshot = None
194
+ self._prefetched = None
195
+ self._lock = _threading.RLock()
196
+
197
+ @property
198
+ def lock(self):
199
+ """Held across a read of the shared handle and the use of its
200
+ result: another thread's rewrite reopens the handle."""
201
+ return self._lock
202
+
203
+ def _offer(self, data: bytes) -> None:
204
+ """Hands over the bytes of a non-local outer that were read to decide
205
+ its format, so opening does not fetch them a second time."""
206
+ with self._lock:
207
+ if self._handle is None and self._prefetched is None:
208
+ self._prefetched = data
209
+
210
+ def _outer_bytes(self) -> bytes:
211
+ """The whole outer archive of a non-local outer."""
212
+ with self._lock:
213
+ data, self._prefetched = self._prefetched, None
214
+ return self.outer.read_bytes() if data is None else data
215
+
216
+ def _is_format_error(self, error: Exception) -> bool:
217
+ """Whether `error`, raised by the decoder, says the archive is damaged."""
218
+ return False
219
+
220
+ @_contextlib.contextmanager
221
+ def _damage_as_format_error(self):
222
+ """Re-raises what a decoder raises for a damaged archive as the
223
+ format's own error, so a caller handles one family per format.
224
+ Anything else (a missing member, an I/O error) passes unchanged."""
225
+ try:
226
+ yield
227
+ except Exception as error:
228
+ if isinstance(error, self._FORMAT_ERROR) or not self._is_format_error(
229
+ error
230
+ ):
231
+ raise
232
+ raise self._FORMAT_ERROR(str(error) or type(error).__name__) from error
233
+
234
+ def __del__(self):
235
+ try:
236
+ self._close_handle()
237
+ except Exception:
238
+ pass
239
+
240
+ def _open(self):
241
+ raise NotImplementedError
242
+
243
+ def _close_handle(self):
244
+ # Drop the cached handle so the next operation (through any instance sharing this
245
+ # backend) reopens and sees the change. The snapshot derives from that handle's
246
+ # entries, so it goes too; every mutation replaces the archive (`_replace_outer`).
247
+ handle = self._handle
248
+ self._handle = None
249
+ self._snapshot = None
250
+ if handle is not None:
251
+ handle.close()
252
+
253
+ def refresh(self) -> None:
254
+ """Forget the handle and any bytes kept from the outer archive; the
255
+ next use reads the outer again."""
256
+ with self._lock:
257
+ self._prefetched = None
258
+ self._close_handle()
259
+
260
+ def _outer_signature(self):
261
+ local = _local_outer_path(self.outer)
262
+ if local is None:
263
+ return None
264
+ try:
265
+ st = _os.stat(local)
266
+ except OSError:
267
+ return None
268
+ return st.st_ino, st.st_size, st.st_mtime_ns
269
+
270
+ def _outer_file(self) -> str:
271
+ """The local file of the outer archive; `ValueError` for any other."""
272
+ local = _local_outer_path(self.outer)
273
+ if local is None:
274
+ raise ValueError(f"the archive {self.outer} is not a local file")
275
+ return local
276
+
277
+ @property
278
+ def handle(self):
279
+ with self._lock:
280
+ signature = self._outer_signature()
281
+ if self._handle is not None and signature != self._signature:
282
+ self._close_handle()
283
+ if self._handle is None:
284
+ self._handle = self._open()
285
+ self._signature = signature
286
+ return self._handle
287
+
288
+ @property
289
+ def writable(self) -> bool:
290
+ return False
291
+
292
+ def snapshot(self) -> _Snapshot:
293
+ """The members of the open handle, derived once and cached with it
294
+ (see `_Snapshot`). Revalidates the handle once; a reopen drops the
295
+ cached one. Every listing, stat, read and write starts here, and
296
+ rebuilding it from the entries each time made an operation on a
297
+ 20k-member archive scan all of them."""
298
+ with self._lock:
299
+ self.handle # revalidates, and clears the snapshot if it reopened
300
+ if self._snapshot is None:
301
+ entries = self._entries()
302
+ self._snapshot = _Snapshot(
303
+ type(self), entries, self._link_texts(entries)
304
+ )
305
+ return self._snapshot
306
+
307
+ def member_index(self) -> "dict[str, str]":
308
+ """Normalized member name -> the key this backend knows it by."""
309
+ return self.snapshot().index
310
+
311
+ def raw_names(self, normalized: str) -> "tuple[str, ...]":
312
+ return self.snapshot().raw_names(normalized)
313
+
314
+ def _entries(self) -> "list[tuple[str, object]]":
315
+ """`(key, info)` for every entry of the open handle, in archive
316
+ order; reads nothing from the archive."""
317
+ raise NotImplementedError
318
+
319
+ def _link_texts(self, entries) -> "dict[str, str | None]":
320
+ """`{key: stored text}` for the entries that are symbolic links (the
321
+ text is None when it cannot be read); empty for a format without."""
322
+ return {}
323
+
324
+ @staticmethod
325
+ def _is_directory(info) -> bool:
326
+ raise NotImplementedError
327
+
328
+ @staticmethod
329
+ def _stat_of(info, snapshot: _Snapshot) -> FileStat:
330
+ raise NotImplementedError
331
+
332
+ def read_member(self, path: str):
333
+ raise NotImplementedError
334
+
335
+
336
+ def _detect_backend_cls(outer: "Path") -> "tuple[type, bytes | None]":
337
+ """Detect zip vs tar for `outer` (extension, then magic-byte sniff --
338
+ see `detect_format`): the matching backend class and, when
339
+ the sniff had to read a non-local outer whole, those bytes. An error
340
+ reading the outer propagates. Lazily imports `._zip`/`._tar` (rather than
341
+ at module level) to avoid a circular import: both submodules import
342
+ `_ArchiveBackend` from this module."""
343
+ from ._pack import detect_format
344
+ from ._tar import _TarBackend
345
+ from ._zip import _ZipBackend
346
+
347
+ local = _local_outer_path(outer) is not None
348
+ whole: "list[bytes]" = []
349
+
350
+ def _peek() -> bytes:
351
+ if local:
352
+ with outer.open("rb") as f:
353
+ return f.read(4)
354
+ whole.append(outer.read_bytes())
355
+ return whole[0][:4]
356
+
357
+ fmt = detect_format(outer.name, _peek)
358
+ return (_ZipBackend if fmt == "zip" else _TarBackend), (whole[0] if whole else None)
359
+
360
+
361
+ class _UndecidedBackend:
362
+ """Stands in for the backend of an `ArchivePath` until something needs
363
+ it: the format is settled on first use, from the extension or, failing
364
+ that, from the bytes of the outer archive. Building a path, printing it
365
+ and joining names onto it never reach the outer. A failure to read the
366
+ outer propagates and decides nothing, so the next use tries again.
367
+ `shared` and `identity` are what `_get_backend` is asked with."""
368
+
369
+ __slots__ = ("outer", "_lock", "_resolved", "_shared", "_identity", "__weakref__")
370
+
371
+ def __init__(self, outer: "Path", shared: bool = True, identity: object = None):
372
+ self.outer = outer
373
+ self._lock = _threading.RLock()
374
+ self._resolved = None
375
+ self._shared = shared
376
+ self._identity = identity
377
+
378
+ def resolve(self) -> "_ArchiveBackend":
379
+ resolved = self._resolved
380
+ if resolved is None:
381
+ with self._lock:
382
+ resolved = self._resolved
383
+ if resolved is None:
384
+ backend_cls, data = _detect_backend_cls(self.outer)
385
+ if not self._shared:
386
+ # It must not borrow a handle another path opened.
387
+ resolved = backend_cls(self.outer)
388
+ else:
389
+ resolved = _get_backend(backend_cls, self.outer, self._identity)
390
+ if data is not None:
391
+ resolved._offer(data)
392
+ self._resolved = resolved
393
+ return resolved
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from .._names import is_safe_child_name
4
+
5
+
6
+ def _is_safe_member_name(name: str) -> bool:
7
+ """Whether archive member `name` is a usable relative path inside the
8
+ archive: every part a real name, none of them escaping the root.
9
+
10
+ Only the rules that hold on every platform are applied here, because an
11
+ archive has no platform of its own: `\\`, `:` and trailing dots are
12
+ ordinary filename characters on POSIX, and an archive written there may
13
+ legitimately contain `C:drive.txt` or `a\\b`. Refusing to *join* such a
14
+ name onto a destination that reads it differently is the destination's
15
+ rule, and is applied per target where the joining happens --
16
+ `Path.copy(recursive=True)`, `PathSyncer` and `unpack_archive()`
17
+ each check `is_safe_child_name(..., windows=is_windows_flavoured(dest))`.
18
+ A trailing `/` (a directory marker) is allowed."""
19
+ if name.endswith("/"):
20
+ name = name[:-1]
21
+ return all(is_safe_child_name(part) for part in name.split("/"))
22
+
23
+
24
+ def _normalize_member_name(name: str) -> "str | None":
25
+ """`name` as a normalized POSIX relative path, or None if it does not
26
+ stay inside the archive root.
27
+
28
+ A member name is a relative path, and writers spell it several ways for
29
+ the same file: `./x` (`tar -C dir .`, `TarFile.add(arcname=".")`,
30
+ `shutil.make_archive`), `a//b`, `a/./b`, `a/b/../c`. Normalizing it the
31
+ way a URI reference resolves -- RFC 3986 dot-segment removal, with empty
32
+ segments dropped -- gives one member one name, so a listing and a lookup
33
+ agree however the archive was written, and the same spelling addresses
34
+ the same member in a zip and in a tar.
35
+
36
+ A name that would leave the root has no normalized form inside the
37
+ archive and is rejected (None): absolute (`/abs`), or with more `..`
38
+ than parts to spend them on. `..` that stays inside is resolved
39
+ (`pkg/../ok.txt` is `ok.txt`). A drive-shaped part (`C:x`) is NOT
40
+ rejected here: it is a legal POSIX filename, and only a destination
41
+ that reads names with Windows rules is endangered by it -- see
42
+ `_is_safe_member_name`. A trailing `/` (a directory marker) is kept, and
43
+ the archive root itself normalizes to "".
44
+ """
45
+ if name.startswith("/"):
46
+ return None
47
+ directory = name.endswith("/")
48
+ parts: "list[str]" = []
49
+ for part in name.split("/"):
50
+ if part in ("", "."):
51
+ continue
52
+ if part == "..":
53
+ if not parts:
54
+ return None # escapes the archive root
55
+ parts.pop()
56
+ continue
57
+ parts.append(part)
58
+ normalized = "/".join(parts)
59
+ if not normalized:
60
+ return "" # the archive root ("", ".", "./", "a/..")
61
+ if not _is_safe_member_name(normalized):
62
+ return None
63
+ return normalized + "/" if directory else normalized