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/_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
|