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/_tar.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io as _io
|
|
4
|
+
import stat as _stat
|
|
5
|
+
import tarfile as _tarfile
|
|
6
|
+
import zlib as _zlib
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from lzma import LZMAError as _LZMAError
|
|
10
|
+
except ImportError: # pragma: no cover - a Python built without lzma
|
|
11
|
+
_LZMAError = _zlib.error
|
|
12
|
+
|
|
13
|
+
from .._filestat import FileStat
|
|
14
|
+
from ._backend import _ArchiveBackend, _LazyReadFile, _local_outer_path, _spool
|
|
15
|
+
from ._names import _normalize_member_name
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _TarBackend(_ArchiveBackend):
|
|
19
|
+
"""A tar archive, plain or compressed. A local one is read from the file
|
|
20
|
+
as it is needed (a compressed one is decompressed from its start for
|
|
21
|
+
each member it reaches), a non-local one is held in memory whole."""
|
|
22
|
+
|
|
23
|
+
__slots__ = ("_members", "_source")
|
|
24
|
+
|
|
25
|
+
_FORMAT_ERROR = _tarfile.ReadError
|
|
26
|
+
|
|
27
|
+
def __init__(self, outer):
|
|
28
|
+
super().__init__(outer)
|
|
29
|
+
self._members = {}
|
|
30
|
+
self._source = None
|
|
31
|
+
|
|
32
|
+
def _is_format_error(self, error):
|
|
33
|
+
# What the decompressors raise over an archive that is cut short or
|
|
34
|
+
# damaged. Their `OSError`s (a bad gzip header or CRC, bzip2's
|
|
35
|
+
# "Invalid data stream") carry no errno, which an I/O failure does.
|
|
36
|
+
return isinstance(
|
|
37
|
+
error, (_tarfile.TarError, EOFError, _zlib.error, _LZMAError)
|
|
38
|
+
) or (isinstance(error, OSError) and error.errno is None)
|
|
39
|
+
|
|
40
|
+
def _open(self):
|
|
41
|
+
local = _local_outer_path(self.outer)
|
|
42
|
+
if local is not None:
|
|
43
|
+
source = _LazyReadFile(local)
|
|
44
|
+
else:
|
|
45
|
+
source = _io.BytesIO(self._outer_bytes())
|
|
46
|
+
try:
|
|
47
|
+
with self._damage_as_format_error():
|
|
48
|
+
handle = _tarfile.open(fileobj=source, mode="r:*")
|
|
49
|
+
members = {}
|
|
50
|
+
for info in handle.getmembers():
|
|
51
|
+
# A later entry of the same name wins, as in tarfile itself.
|
|
52
|
+
members[info.name] = info
|
|
53
|
+
except BaseException:
|
|
54
|
+
source.close()
|
|
55
|
+
raise
|
|
56
|
+
self._members = members
|
|
57
|
+
self._source = source
|
|
58
|
+
self._release()
|
|
59
|
+
return handle
|
|
60
|
+
|
|
61
|
+
def _release(self):
|
|
62
|
+
# No OS handle is kept between operations: on Windows one blocks
|
|
63
|
+
# deleting or replacing the archive.
|
|
64
|
+
if isinstance(self._source, _LazyReadFile):
|
|
65
|
+
self._source.release()
|
|
66
|
+
|
|
67
|
+
def _close_handle(self):
|
|
68
|
+
source, self._source = self._source, None
|
|
69
|
+
super()._close_handle()
|
|
70
|
+
if source is not None:
|
|
71
|
+
source.close()
|
|
72
|
+
|
|
73
|
+
def _entries(self):
|
|
74
|
+
return list(self._members.items())
|
|
75
|
+
|
|
76
|
+
def _link_texts(self, entries):
|
|
77
|
+
return {raw: info.linkname for raw, info in entries if info.issym()}
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _is_directory(info):
|
|
81
|
+
return info.isdir()
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _stat_of(info, snapshot):
|
|
85
|
+
size = info.size
|
|
86
|
+
if info.isdir():
|
|
87
|
+
kind = _stat.S_IFDIR
|
|
88
|
+
elif info.isfile() or info.islnk():
|
|
89
|
+
kind = _stat.S_IFREG
|
|
90
|
+
if info.islnk():
|
|
91
|
+
# A hard link carries no data of its own.
|
|
92
|
+
target = _normalize_member_name(info.linkname)
|
|
93
|
+
raw = snapshot.index.get(target) if target else None
|
|
94
|
+
size = snapshot.infos[raw].size if raw is not None else 0
|
|
95
|
+
elif info.issym():
|
|
96
|
+
kind = _stat.S_IFLNK
|
|
97
|
+
size = len(info.linkname.encode("utf-8", "surrogateescape"))
|
|
98
|
+
else:
|
|
99
|
+
# Special files are not modelled: no mode.
|
|
100
|
+
kind = 0
|
|
101
|
+
perms = _stat.S_IMODE(info.mode)
|
|
102
|
+
if kind == _stat.S_IFLNK:
|
|
103
|
+
perms = perms or 0o777
|
|
104
|
+
return FileStat(
|
|
105
|
+
st_mode=kind | perms if kind and perms else None,
|
|
106
|
+
st_size=size,
|
|
107
|
+
st_mtime=int(info.mtime),
|
|
108
|
+
is_dir=info.isdir(),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def read_member(self, path):
|
|
112
|
+
with self._lock:
|
|
113
|
+
handle = self.handle
|
|
114
|
+
member = self._members[path] # raises KeyError if missing
|
|
115
|
+
try:
|
|
116
|
+
with self._damage_as_format_error():
|
|
117
|
+
f = handle.extractfile(member)
|
|
118
|
+
if f is None:
|
|
119
|
+
raise IsADirectoryError(path)
|
|
120
|
+
# Copied out under the lock: a live stream shares the
|
|
121
|
+
# handle's file object (and decompressor) with every other
|
|
122
|
+
# reader. A large member goes to a temporary file.
|
|
123
|
+
with self._damage_as_format_error(), f:
|
|
124
|
+
return _spool(f)
|
|
125
|
+
finally:
|
|
126
|
+
self._release()
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib as _contextlib
|
|
4
|
+
import errno as _errno
|
|
5
|
+
import typing as _ty
|
|
6
|
+
|
|
7
|
+
from .._errorpolicy import as_error_handler, check_follow
|
|
8
|
+
from .._commit import CommitOnClose
|
|
9
|
+
from ._names import _normalize_member_name
|
|
10
|
+
from ._snapshot import _Snapshot
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _WritableMembers:
|
|
14
|
+
"""What an archive path does to change its archive: gated on
|
|
15
|
+
`self.backend.writable`, which only the zip backend, and only for a local
|
|
16
|
+
archive file, ever reports `True` -- so a tar-backed path, whether reached
|
|
17
|
+
as `TarPath` or auto-detected, correctly raises `NotImplementedError` on
|
|
18
|
+
any write attempt rather than silently misbehaving."""
|
|
19
|
+
|
|
20
|
+
__slots__ = ()
|
|
21
|
+
|
|
22
|
+
def _write_stream(self, name: str, initial: "bytes | None" = None):
|
|
23
|
+
"""A write buffer whose content becomes the member `name` (a key of
|
|
24
|
+
the backend) when it is closed."""
|
|
25
|
+
backend = self.backend
|
|
26
|
+
return CommitOnClose(
|
|
27
|
+
lambda buffer: backend.write_member(name, buffer.getvalue()), initial
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def _member_for_write(self) -> str:
|
|
31
|
+
member = self._member
|
|
32
|
+
if member is None:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"member name escapes the archive root: {self.as_posix()!r}"
|
|
35
|
+
)
|
|
36
|
+
return member
|
|
37
|
+
|
|
38
|
+
def _check_no_link(self, snapshot: _Snapshot, member: str, *, last: bool) -> None:
|
|
39
|
+
"""Writes go to the member named, never through a link: the member
|
|
40
|
+
would land beside the link and no listing would show it."""
|
|
41
|
+
if not snapshot.links:
|
|
42
|
+
return
|
|
43
|
+
parts = member.split("/")
|
|
44
|
+
for count in range(1, len(parts) + (1 if last else 0)):
|
|
45
|
+
if "/".join(parts[:count]) in snapshot.links:
|
|
46
|
+
raise NotImplementedError(
|
|
47
|
+
f"{self._SCHEME}: writing through a link member is not "
|
|
48
|
+
f"supported: {self}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def _snapshot_or_empty(self) -> _Snapshot:
|
|
52
|
+
"""The snapshot a write checks the member against; an archive that is
|
|
53
|
+
not there yet holds nothing."""
|
|
54
|
+
try:
|
|
55
|
+
return self._snapshot()
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
return _Snapshot(type(self.backend), [])
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _emptied_parent(
|
|
61
|
+
member: str, index: "dict[str, str]", removed, added: "str | None" = None
|
|
62
|
+
) -> "str | None":
|
|
63
|
+
"""The directory marker (`"a/b/"`) to write when taking `member` out
|
|
64
|
+
of its directory would leave that directory with nothing in it. A zip
|
|
65
|
+
directory may exist only through its members, and `removed` (the
|
|
66
|
+
normalized names leaving) plus `added` (a name arriving) decide
|
|
67
|
+
whether any remain; removing a file never removes its parent."""
|
|
68
|
+
parent = member.rstrip("/").rpartition("/")[0]
|
|
69
|
+
if not parent:
|
|
70
|
+
return None
|
|
71
|
+
marker = f"{parent}/"
|
|
72
|
+
if added is not None and added.startswith(marker):
|
|
73
|
+
return None
|
|
74
|
+
for name in index:
|
|
75
|
+
if name.startswith(marker) and name not in removed:
|
|
76
|
+
return None
|
|
77
|
+
return marker
|
|
78
|
+
|
|
79
|
+
def _check_parent(self):
|
|
80
|
+
"""pathlib's check before creating `self`: the parent must exist and
|
|
81
|
+
be a directory (a zip directory may be implicit, see `stat`)."""
|
|
82
|
+
parent = self.parent
|
|
83
|
+
if not parent.as_posix().strip("/"):
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
is_dir = parent.stat().is_dir()
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
raise FileNotFoundError(
|
|
89
|
+
_errno.ENOENT, "No such file or directory", str(self)
|
|
90
|
+
) from None
|
|
91
|
+
if not is_dir:
|
|
92
|
+
raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
|
|
93
|
+
|
|
94
|
+
def _require_writable(self):
|
|
95
|
+
backend = self.backend
|
|
96
|
+
if getattr(backend, "writable", False):
|
|
97
|
+
return
|
|
98
|
+
if hasattr(backend, "write_member"):
|
|
99
|
+
# A write-capable backend type (currently only the zip one),
|
|
100
|
+
# just not writable for *this* archive file (a non-local one).
|
|
101
|
+
raise NotImplementedError(
|
|
102
|
+
f"{self._SCHEME}: write support requires a local archive file"
|
|
103
|
+
)
|
|
104
|
+
raise NotImplementedError(
|
|
105
|
+
f"{self._SCHEME}: write support is only available for "
|
|
106
|
+
"zip-format archives"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def batch(self) -> "_ty.ContextManager[None]":
|
|
110
|
+
"""A context manager that holds every change made to this archive
|
|
111
|
+
while it is open and writes them in one replacement of the archive
|
|
112
|
+
file when the outermost block ends without an exception; an exception
|
|
113
|
+
leaves the archive as it was. Inside it every path of the archive
|
|
114
|
+
reads the held changes, and the file is not touched until the block
|
|
115
|
+
ends. A block inside another joins it. `NotImplementedError` when the
|
|
116
|
+
archive cannot be written (see `_require_writable`)."""
|
|
117
|
+
self._require_writable()
|
|
118
|
+
return self.backend.batch()
|
|
119
|
+
|
|
120
|
+
def _write_batch(self) -> "_ty.ContextManager[object]":
|
|
121
|
+
# Entered by `copy(recursive=True)` and `PathSyncer` around a tree of
|
|
122
|
+
# writes. It must read nothing and fail never: the write that follows
|
|
123
|
+
# reports a missing or unwritable archive itself.
|
|
124
|
+
try:
|
|
125
|
+
backend = self.backend
|
|
126
|
+
writable = getattr(backend, "writable", False)
|
|
127
|
+
except Exception:
|
|
128
|
+
writable = False
|
|
129
|
+
if not (writable and hasattr(backend, "batch")):
|
|
130
|
+
return _contextlib.nullcontext()
|
|
131
|
+
return backend.batch()
|
|
132
|
+
|
|
133
|
+
def _open_for_write(self, mode: str):
|
|
134
|
+
self._require_writable()
|
|
135
|
+
self._check_no_link(
|
|
136
|
+
self._snapshot_or_empty(), self._member_for_write(), last=True
|
|
137
|
+
)
|
|
138
|
+
if "r" in mode:
|
|
139
|
+
# Read-modify-write: what is written reaches the archive on close.
|
|
140
|
+
data = self._read_member().read()
|
|
141
|
+
return self._write_stream(
|
|
142
|
+
self._raw_name(self._member_for_write()), initial=data
|
|
143
|
+
)
|
|
144
|
+
if mode not in ("w", "x"):
|
|
145
|
+
raise NotImplementedError(f"open(mode={mode!r})")
|
|
146
|
+
if self._is_dir_member():
|
|
147
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
148
|
+
if mode == "x" and self.exists():
|
|
149
|
+
raise FileExistsError(str(self))
|
|
150
|
+
self._check_parent()
|
|
151
|
+
# The raw name: overwriting a member the archive spells "./f.txt" rewrites THAT
|
|
152
|
+
# entry instead of appending a second one that shadows it.
|
|
153
|
+
return self._write_stream(self._raw_name(self._member_for_write()))
|
|
154
|
+
|
|
155
|
+
def _mkdir(self, mode):
|
|
156
|
+
self._require_writable()
|
|
157
|
+
if not self._member_for_write():
|
|
158
|
+
# The archive root is the archive itself: it exists, or there is
|
|
159
|
+
# nothing to create it in.
|
|
160
|
+
self.stat()
|
|
161
|
+
raise FileExistsError(str(self))
|
|
162
|
+
snapshot = self._snapshot_or_empty()
|
|
163
|
+
if self.exists() or self._member_for_write() in snapshot.links:
|
|
164
|
+
raise FileExistsError(str(self))
|
|
165
|
+
self._check_no_link(snapshot, self._member_for_write(), last=False)
|
|
166
|
+
self._check_parent()
|
|
167
|
+
marker = f"{self._member_for_write()}/"
|
|
168
|
+
self.backend.write_member(self._raw_name(marker), b"")
|
|
169
|
+
|
|
170
|
+
def unlink(self, missing_ok=False):
|
|
171
|
+
self._require_writable()
|
|
172
|
+
path = self._member_for_write()
|
|
173
|
+
snapshot = self._snapshot()
|
|
174
|
+
self._check_no_link(snapshot, path, last=False)
|
|
175
|
+
index = snapshot.index
|
|
176
|
+
if path not in index or snapshot.hidden(path):
|
|
177
|
+
if self._is_dir_member():
|
|
178
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(self))
|
|
179
|
+
if missing_ok:
|
|
180
|
+
return
|
|
181
|
+
raise FileNotFoundError(str(self))
|
|
182
|
+
self.backend.delete_members(
|
|
183
|
+
snapshot.raw_names(path),
|
|
184
|
+
keep_dir=self._emptied_parent(path, index, {path}),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def rmdir(self):
|
|
188
|
+
self._require_writable()
|
|
189
|
+
path = self._member_for_write()
|
|
190
|
+
snapshot = self._snapshot()
|
|
191
|
+
self._check_no_link(snapshot, path, last=False)
|
|
192
|
+
index = snapshot.index
|
|
193
|
+
if not path:
|
|
194
|
+
# The archive root cannot be removed: an empty one is a no-op.
|
|
195
|
+
if index:
|
|
196
|
+
raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
|
|
197
|
+
return
|
|
198
|
+
marker = f"{path}/"
|
|
199
|
+
if snapshot.hidden(path):
|
|
200
|
+
raise FileNotFoundError(str(self))
|
|
201
|
+
if any(n != marker and n.startswith(marker) for n in index):
|
|
202
|
+
raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
|
|
203
|
+
if marker not in index:
|
|
204
|
+
if path in index:
|
|
205
|
+
raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(self))
|
|
206
|
+
raise FileNotFoundError(str(self))
|
|
207
|
+
self.backend.delete_members(
|
|
208
|
+
snapshot.raw_names(marker),
|
|
209
|
+
keep_dir=self._emptied_parent(marker, index, {marker}),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def rm(
|
|
213
|
+
self,
|
|
214
|
+
/,
|
|
215
|
+
recursive=False,
|
|
216
|
+
missing_ok=False,
|
|
217
|
+
ignore_error: "bool | _ty.Callable[[Exception, _ty.Self], bool]" = False,
|
|
218
|
+
*,
|
|
219
|
+
follow_symlinks=False,
|
|
220
|
+
follow_binds=False,
|
|
221
|
+
):
|
|
222
|
+
# An archive holds no bindings, and a link member is an entry like
|
|
223
|
+
# any other for a removal that does not follow it.
|
|
224
|
+
check_follow("follow_symlinks", follow_symlinks)
|
|
225
|
+
check_follow("follow_binds", follow_binds)
|
|
226
|
+
if not (
|
|
227
|
+
recursive
|
|
228
|
+
and self._rm_tree(
|
|
229
|
+
as_error_handler(ignore_error), follows=follow_symlinks is not False
|
|
230
|
+
)
|
|
231
|
+
):
|
|
232
|
+
return super().rm(
|
|
233
|
+
recursive=recursive,
|
|
234
|
+
missing_ok=missing_ok,
|
|
235
|
+
ignore_error=ignore_error,
|
|
236
|
+
follow_symlinks=follow_symlinks,
|
|
237
|
+
follow_binds=follow_binds,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _rm_tree(self, on_error, follows: bool = False) -> bool:
|
|
241
|
+
"""`rm(recursive=True)` of a directory of a writable archive: every
|
|
242
|
+
member under it leaves in ONE rewrite of the archive. False when this
|
|
243
|
+
is not that case, and the generic removal handles (and reports) it:
|
|
244
|
+
a policy that follows links, or leaves them, has to meet each one.
|
|
245
|
+
`on_error(error, self)` is asked about a failure and returns whether to
|
|
246
|
+
carry on."""
|
|
247
|
+
member = self._member
|
|
248
|
+
if member is None or not getattr(self.backend, "writable", False):
|
|
249
|
+
return False
|
|
250
|
+
try:
|
|
251
|
+
snapshot = self._snapshot()
|
|
252
|
+
found = snapshot.stat(member)
|
|
253
|
+
if found is None or not found.is_dir():
|
|
254
|
+
return False
|
|
255
|
+
index = snapshot.index
|
|
256
|
+
except Exception:
|
|
257
|
+
return False
|
|
258
|
+
marker = f"{member}/" if member else ""
|
|
259
|
+
under = [name for name in index if name.startswith(marker)]
|
|
260
|
+
if follows and any(name in snapshot.links for name in under):
|
|
261
|
+
return False
|
|
262
|
+
try:
|
|
263
|
+
# A member under a file is not listed, so a removal that walked
|
|
264
|
+
# the listing would leave it behind and the directory would
|
|
265
|
+
# come back.
|
|
266
|
+
hidden = [name for name in under if snapshot.hidden(name)]
|
|
267
|
+
if hidden:
|
|
268
|
+
raise OSError(
|
|
269
|
+
_errno.ENOTEMPTY,
|
|
270
|
+
f"Directory not empty: {hidden[0]!r} lies under a file",
|
|
271
|
+
str(self),
|
|
272
|
+
)
|
|
273
|
+
if under:
|
|
274
|
+
self.backend.delete_members(
|
|
275
|
+
[raw for name in under for raw in snapshot.raw_names(name)],
|
|
276
|
+
keep_dir=(
|
|
277
|
+
self._emptied_parent(member, index, set(under))
|
|
278
|
+
if member
|
|
279
|
+
else None
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
except Exception as error:
|
|
283
|
+
if not on_error(error, self):
|
|
284
|
+
raise
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
def rename(self, target):
|
|
288
|
+
# A plain str target is a sibling rename (relative to self's parent), as in sftp
|
|
289
|
+
# and ftp. An existing target is replaced as POSIX rename(2) does (a file over a
|
|
290
|
+
# file, a directory over an empty one), never a duplicate member. Returns the new path.
|
|
291
|
+
self._require_writable()
|
|
292
|
+
if isinstance(target, str):
|
|
293
|
+
target = self.parent / target
|
|
294
|
+
elif not isinstance(target, type(self)) or not self._same_filesystem(target):
|
|
295
|
+
raise NotImplementedError("rename() onto another archive")
|
|
296
|
+
old_path = self._member_for_write()
|
|
297
|
+
new_path = _normalize_member_name(target.as_posix().lstrip("/"))
|
|
298
|
+
if not new_path:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
f"member name escapes the archive root: {target.as_posix()!r}"
|
|
301
|
+
)
|
|
302
|
+
snapshot = self._snapshot()
|
|
303
|
+
self._check_no_link(snapshot, old_path, last=False)
|
|
304
|
+
self._check_no_link(snapshot, new_path, last=False)
|
|
305
|
+
index = snapshot.index
|
|
306
|
+
names = list(index)
|
|
307
|
+
marker = f"{old_path}/"
|
|
308
|
+
new_marker = f"{new_path}/"
|
|
309
|
+
renamed = self._rebuild(new_path)
|
|
310
|
+
backend = self.backend
|
|
311
|
+
hidden = snapshot.hidden(old_path)
|
|
312
|
+
is_file = old_path in index and not hidden
|
|
313
|
+
is_dir = not is_file and not hidden and any(n.startswith(marker) for n in names)
|
|
314
|
+
if not (is_file or is_dir):
|
|
315
|
+
raise FileNotFoundError(str(self))
|
|
316
|
+
if is_dir and new_path.startswith(marker):
|
|
317
|
+
raise OSError(
|
|
318
|
+
_errno.EINVAL, "Cannot move a directory into itself", str(target)
|
|
319
|
+
)
|
|
320
|
+
renamed._check_parent()
|
|
321
|
+
if is_file:
|
|
322
|
+
if new_path == old_path:
|
|
323
|
+
return renamed
|
|
324
|
+
if any(n.startswith(new_marker) for n in names):
|
|
325
|
+
raise IsADirectoryError(_errno.EISDIR, "Is a directory", str(target))
|
|
326
|
+
# Every spelling of the source moves, and every spelling of an
|
|
327
|
+
# existing target goes: one left behind would shadow, or come
|
|
328
|
+
# back as, the member this call just placed.
|
|
329
|
+
backend.rename_member(
|
|
330
|
+
index[old_path],
|
|
331
|
+
new_path,
|
|
332
|
+
members={raw: new_path for raw in snapshot.raw_names(old_path)},
|
|
333
|
+
replace=snapshot.raw_names(new_path),
|
|
334
|
+
keep_dir=self._emptied_parent(old_path, index, {old_path}, new_path),
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
if new_marker == marker:
|
|
338
|
+
return renamed
|
|
339
|
+
if new_path in index:
|
|
340
|
+
raise NotADirectoryError(_errno.ENOTDIR, "Not a directory", str(target))
|
|
341
|
+
if any(n != new_marker and n.startswith(new_marker) for n in names):
|
|
342
|
+
raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(target))
|
|
343
|
+
# The backend renames by its own keys, and a prefix match on the normalized marker
|
|
344
|
+
# misses members spelled "./dir/x" (renaming nothing, or splitting the directory):
|
|
345
|
+
# hand it the exact raw-name mapping.
|
|
346
|
+
moving = [name for name in names if name.startswith(marker)]
|
|
347
|
+
members = {
|
|
348
|
+
raw: new_marker + name[len(marker) :]
|
|
349
|
+
for name in moving
|
|
350
|
+
for raw in snapshot.raw_names(name)
|
|
351
|
+
}
|
|
352
|
+
backend.rename_member(
|
|
353
|
+
index.get(marker, marker),
|
|
354
|
+
new_marker,
|
|
355
|
+
members=members,
|
|
356
|
+
replace=snapshot.raw_names(new_marker),
|
|
357
|
+
keep_dir=self._emptied_parent(old_path, index, set(moving), new_path),
|
|
358
|
+
)
|
|
359
|
+
return renamed
|