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/_zip.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno as _errno
|
|
4
|
+
import io as _io
|
|
5
|
+
import os as _os
|
|
6
|
+
import shutil as _shutil
|
|
7
|
+
import stat as _stat
|
|
8
|
+
import struct as _struct
|
|
9
|
+
import tempfile as _tempfile
|
|
10
|
+
import time as _time
|
|
11
|
+
import zipfile as _zipfile
|
|
12
|
+
import zlib as _zlib
|
|
13
|
+
from contextlib import contextmanager as _contextmanager
|
|
14
|
+
|
|
15
|
+
from .._filestat import FileStat
|
|
16
|
+
from ._backend import _ArchiveBackend, _LazyReadFile, _local_outer_path, _spool
|
|
17
|
+
from ._snapshot import NO_LINK as _NO_LINK
|
|
18
|
+
from ._snapshot import _Snapshot
|
|
19
|
+
from ._zipentries import (
|
|
20
|
+
_MAX_LINK_TEXT,
|
|
21
|
+
_Batch,
|
|
22
|
+
_collapse,
|
|
23
|
+
_copy_entry,
|
|
24
|
+
_copy_range,
|
|
25
|
+
_copy_zipinfo,
|
|
26
|
+
_is_link,
|
|
27
|
+
_member_mode,
|
|
28
|
+
_new_info,
|
|
29
|
+
_now,
|
|
30
|
+
_Pending,
|
|
31
|
+
_remapper,
|
|
32
|
+
_renamed_info,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _ZipBackend(_ArchiveBackend):
|
|
37
|
+
__slots__ = ("_batch",)
|
|
38
|
+
|
|
39
|
+
_FORMAT_ERROR = _zipfile.BadZipFile
|
|
40
|
+
|
|
41
|
+
def __init__(self, outer):
|
|
42
|
+
super().__init__(outer)
|
|
43
|
+
self._batch = None
|
|
44
|
+
|
|
45
|
+
def _is_format_error(self, error):
|
|
46
|
+
# `zipfile` lets the decoders' own errors through for a damaged archive: an inflate
|
|
47
|
+
# failure, a short header, a truncated stream, an undecodable name, an offset that
|
|
48
|
+
# cannot be seeked to, an unknown version (`NotImplementedError` in the directory).
|
|
49
|
+
return isinstance(
|
|
50
|
+
error,
|
|
51
|
+
(_zlib.error, _struct.error, EOFError, UnicodeDecodeError, OverflowError),
|
|
52
|
+
) or (isinstance(error, OSError) and error.errno == _errno.EINVAL)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def writable(self) -> bool:
|
|
56
|
+
# Writing entries needs a real seekable local file -- not a
|
|
57
|
+
# remote/embedded outer URI. The shared read handle is still opened
|
|
58
|
+
# "r"; writes open their own handle (`write_member`/`_rewrite`).
|
|
59
|
+
return _local_outer_path(self.outer) is not None
|
|
60
|
+
|
|
61
|
+
def _open(self):
|
|
62
|
+
if self.writable:
|
|
63
|
+
# Never "a" for reads: append mode opens the file r+b (fails on a
|
|
64
|
+
# read-only archive), creates a missing file, and on close appends
|
|
65
|
+
# an end-of-central-directory record to a file that is not a zip.
|
|
66
|
+
fp = _LazyReadFile(self._outer_file())
|
|
67
|
+
try:
|
|
68
|
+
return self._read_directory(fp)
|
|
69
|
+
finally:
|
|
70
|
+
fp.release()
|
|
71
|
+
return self._read_directory(_io.BytesIO(self._outer_bytes()))
|
|
72
|
+
|
|
73
|
+
def _read_directory(self, fileobj):
|
|
74
|
+
try:
|
|
75
|
+
with self._damage_as_format_error():
|
|
76
|
+
return _zipfile.ZipFile(fileobj, mode="r")
|
|
77
|
+
except NotImplementedError as error:
|
|
78
|
+
# An extract version the central directory declares and
|
|
79
|
+
# `zipfile` does not know: the directory is not one.
|
|
80
|
+
raise _zipfile.BadZipFile(str(error)) from error
|
|
81
|
+
|
|
82
|
+
def _close_handle(self):
|
|
83
|
+
# `ZipFile.close()` leaves a passed-in file object open.
|
|
84
|
+
fp = getattr(self._handle, "fp", None)
|
|
85
|
+
super()._close_handle()
|
|
86
|
+
if isinstance(fp, _LazyReadFile):
|
|
87
|
+
fp.release()
|
|
88
|
+
|
|
89
|
+
@_contextmanager
|
|
90
|
+
def _reading(self):
|
|
91
|
+
"""The shared handle, under the lock; the OS file handle behind it
|
|
92
|
+
is released again when the operation ends."""
|
|
93
|
+
with self._lock:
|
|
94
|
+
handle = self.handle
|
|
95
|
+
try:
|
|
96
|
+
with self._damage_as_format_error():
|
|
97
|
+
yield handle
|
|
98
|
+
finally:
|
|
99
|
+
if isinstance(handle.fp, _LazyReadFile):
|
|
100
|
+
handle.fp.release()
|
|
101
|
+
|
|
102
|
+
def _entries(self):
|
|
103
|
+
return [(info.filename, info) for info in self._handle.infolist()]
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _is_directory(info):
|
|
107
|
+
return info.filename.endswith("/")
|
|
108
|
+
|
|
109
|
+
def _link_texts(self, entries):
|
|
110
|
+
links = {}
|
|
111
|
+
for raw, info in entries:
|
|
112
|
+
if _is_link(info):
|
|
113
|
+
links[raw] = self._read_link(info)
|
|
114
|
+
return links
|
|
115
|
+
|
|
116
|
+
def _read_link(self, info) -> "str | None":
|
|
117
|
+
if info.file_size > _MAX_LINK_TEXT:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
with self._reading() as handle:
|
|
121
|
+
with handle.open(info) as source:
|
|
122
|
+
data = source.read(_MAX_LINK_TEXT + 1)
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
if len(data) > _MAX_LINK_TEXT:
|
|
126
|
+
return None
|
|
127
|
+
return data.decode("utf-8", "surrogateescape")
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _stat_of(info, snapshot):
|
|
131
|
+
mtime = int(_time.mktime((*info.date_time, 0, 0, -1))) if info.date_time else 0
|
|
132
|
+
if _is_link(info):
|
|
133
|
+
mode = _stat.S_IMODE(info.external_attr >> 16) or 0o777
|
|
134
|
+
return FileStat(
|
|
135
|
+
st_mode=_stat.S_IFLNK | mode, st_size=info.file_size, st_mtime=mtime
|
|
136
|
+
)
|
|
137
|
+
return FileStat(
|
|
138
|
+
st_mode=_member_mode(info),
|
|
139
|
+
st_size=info.file_size,
|
|
140
|
+
st_mtime=mtime,
|
|
141
|
+
is_dir=info.filename.endswith("/"),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def snapshot(self) -> _Snapshot:
|
|
145
|
+
with self._lock:
|
|
146
|
+
batch = self._batch
|
|
147
|
+
if batch is None or not batch.dirty:
|
|
148
|
+
return super().snapshot()
|
|
149
|
+
if batch.snapshot is None:
|
|
150
|
+
members = self._ordered()
|
|
151
|
+
batch.snapshot = _Snapshot(
|
|
152
|
+
type(self),
|
|
153
|
+
[(name, entry.info) for name, entry in members],
|
|
154
|
+
{
|
|
155
|
+
name: entry.link
|
|
156
|
+
for name, entry in members
|
|
157
|
+
if entry.link is not _NO_LINK
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
return batch.snapshot
|
|
161
|
+
|
|
162
|
+
def read_member(self, path):
|
|
163
|
+
with self._lock:
|
|
164
|
+
batch = self._batch
|
|
165
|
+
if batch is not None and batch.dirty:
|
|
166
|
+
entry = self._table()[path] # raises KeyError if missing
|
|
167
|
+
if entry.data is not None:
|
|
168
|
+
return _io.BytesIO(batch.scratch.get(entry.data))
|
|
169
|
+
path = entry.origin
|
|
170
|
+
with self._reading() as handle:
|
|
171
|
+
# Copied out, not the live ZipExtFile: callers may hold the stream across later
|
|
172
|
+
# mutations on this shared backend, which close and reopen the handle. A large
|
|
173
|
+
# member goes to a temporary file, not to memory.
|
|
174
|
+
with handle.open(path) as source: # raises KeyError if missing
|
|
175
|
+
return _spool(source)
|
|
176
|
+
|
|
177
|
+
@_contextmanager
|
|
178
|
+
def batch(self):
|
|
179
|
+
"""Holds every change made while it is open, for every path of this
|
|
180
|
+
archive, and writes them in ONE replacement of the archive when the
|
|
181
|
+
outermost block ends cleanly. An exception leaves the archive as it
|
|
182
|
+
was. The block owns the archive: another thread waits for it."""
|
|
183
|
+
with self._lock:
|
|
184
|
+
outermost = self._batch is None
|
|
185
|
+
if outermost:
|
|
186
|
+
self._batch = _Batch()
|
|
187
|
+
try:
|
|
188
|
+
yield
|
|
189
|
+
if outermost:
|
|
190
|
+
self._flush(self._batch)
|
|
191
|
+
finally:
|
|
192
|
+
if outermost:
|
|
193
|
+
batch, self._batch = self._batch, None
|
|
194
|
+
batch.scratch.close()
|
|
195
|
+
|
|
196
|
+
def _changed(self) -> OSError:
|
|
197
|
+
return OSError(
|
|
198
|
+
_errno.EBUSY,
|
|
199
|
+
"The archive changed while a batch of changes to it was open",
|
|
200
|
+
self._outer_file(),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _table(self) -> "dict[str, _Pending]":
|
|
204
|
+
"""The members of the open batch by name, read from the archive on
|
|
205
|
+
first use. Must be called with `self._lock` held."""
|
|
206
|
+
batch = self._batch
|
|
207
|
+
if batch.table is not None:
|
|
208
|
+
if self._outer_signature() != batch.signature:
|
|
209
|
+
raise self._changed()
|
|
210
|
+
return batch.table
|
|
211
|
+
table: "dict[str, _Pending]" = {}
|
|
212
|
+
outer_path = self._outer_file()
|
|
213
|
+
try:
|
|
214
|
+
with self._reading() as handle:
|
|
215
|
+
infos = handle.infolist()
|
|
216
|
+
links = self._link_texts([(info.filename, info) for info in infos])
|
|
217
|
+
for seq, info in enumerate(infos):
|
|
218
|
+
# The last of several entries of one name is the live one.
|
|
219
|
+
table[info.filename] = _Pending(
|
|
220
|
+
info, info, None, links.get(info.filename, _NO_LINK), seq
|
|
221
|
+
)
|
|
222
|
+
batch.next_seq = len(infos)
|
|
223
|
+
signature = self._signature
|
|
224
|
+
except FileNotFoundError:
|
|
225
|
+
if _os.path.lexists(outer_path):
|
|
226
|
+
raise
|
|
227
|
+
signature = None # the first change creates the archive
|
|
228
|
+
else:
|
|
229
|
+
self._check_writable(_os.path.realpath(outer_path))
|
|
230
|
+
batch.table, batch.signature = table, signature
|
|
231
|
+
return table
|
|
232
|
+
|
|
233
|
+
def _ordered(self) -> "list[tuple[str, _Pending]]":
|
|
234
|
+
"""The members of the open batch in the order of the archive."""
|
|
235
|
+
return sorted(self._table().items(), key=lambda item: item[1].seq)
|
|
236
|
+
|
|
237
|
+
def _attach(self, name: str, entry: _Pending) -> None:
|
|
238
|
+
"""Make `entry` the member `name`. The member list the batch has
|
|
239
|
+
derived takes the change itself; one that cannot (a name the archive
|
|
240
|
+
spells two ways) is derived again on the next read. Must be called with
|
|
241
|
+
`self._lock` held."""
|
|
242
|
+
batch = self._batch
|
|
243
|
+
batch.table[name] = entry
|
|
244
|
+
batch.dirty = True
|
|
245
|
+
if batch.snapshot is not None and not batch.snapshot.add(
|
|
246
|
+
name, entry.info, entry.link
|
|
247
|
+
):
|
|
248
|
+
batch.snapshot = None
|
|
249
|
+
|
|
250
|
+
def _detach(self, name: str) -> "_Pending | None":
|
|
251
|
+
"""Remove the member `name` and return it; see `_attach()`."""
|
|
252
|
+
batch = self._batch
|
|
253
|
+
entry = batch.table.pop(name, None)
|
|
254
|
+
if entry is not None:
|
|
255
|
+
batch.dirty = True
|
|
256
|
+
if batch.snapshot is not None and not batch.snapshot.drop(name):
|
|
257
|
+
batch.snapshot = None
|
|
258
|
+
return entry
|
|
259
|
+
|
|
260
|
+
def _put(self, path: str, data: bytes) -> None:
|
|
261
|
+
"""Set member `path` to `data`, keeping an existing member's place and
|
|
262
|
+
metadata. Must be called with `self._lock` held."""
|
|
263
|
+
batch = self._batch
|
|
264
|
+
table = self._table()
|
|
265
|
+
info = _new_info(path, len(data))
|
|
266
|
+
old = table.get(info.filename)
|
|
267
|
+
if old is not None:
|
|
268
|
+
info = _copy_zipinfo(old.info, info.filename)
|
|
269
|
+
info.date_time = _now()
|
|
270
|
+
info.file_size = len(data)
|
|
271
|
+
seq = old.seq
|
|
272
|
+
else:
|
|
273
|
+
seq = batch.next_seq
|
|
274
|
+
batch.next_seq += 1
|
|
275
|
+
self._attach(
|
|
276
|
+
info.filename,
|
|
277
|
+
_Pending(info, None, batch.scratch.add(data), _NO_LINK, seq),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def _flush(self, batch: _Batch) -> None:
|
|
281
|
+
if not batch.dirty:
|
|
282
|
+
return
|
|
283
|
+
table = batch.table
|
|
284
|
+
outer_path = _os.path.realpath(self._outer_file())
|
|
285
|
+
|
|
286
|
+
members = sorted(table.items(), key=lambda item: item[1].seq)
|
|
287
|
+
|
|
288
|
+
def steps():
|
|
289
|
+
for name, entry in members:
|
|
290
|
+
if entry.data is not None:
|
|
291
|
+
yield name, None, entry.info, batch.scratch.get(entry.data)
|
|
292
|
+
else:
|
|
293
|
+
yield name, entry.origin, None, None
|
|
294
|
+
|
|
295
|
+
if batch.signature is None:
|
|
296
|
+
created = False
|
|
297
|
+
try:
|
|
298
|
+
with open(outer_path, "xb") as fp:
|
|
299
|
+
created = True
|
|
300
|
+
with _zipfile.ZipFile(fp, "w", _zipfile.ZIP_DEFLATED) as dst:
|
|
301
|
+
for _name, _origin, zinfo, data in steps():
|
|
302
|
+
dst.writestr(zinfo, data)
|
|
303
|
+
except BaseException:
|
|
304
|
+
if created:
|
|
305
|
+
try:
|
|
306
|
+
_os.unlink(outer_path)
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
raise
|
|
310
|
+
return
|
|
311
|
+
if self._outer_signature() != batch.signature:
|
|
312
|
+
raise self._changed()
|
|
313
|
+
|
|
314
|
+
def fill(tmp):
|
|
315
|
+
with open(outer_path, "rb") as original:
|
|
316
|
+
with _zipfile.ZipFile(original, "r") as src:
|
|
317
|
+
self._emit(tmp, original, src, steps())
|
|
318
|
+
|
|
319
|
+
self._replace_outer(fill)
|
|
320
|
+
|
|
321
|
+
def write_member(self, path: str, data: bytes):
|
|
322
|
+
with self._lock:
|
|
323
|
+
if self._batch is not None:
|
|
324
|
+
self._put(path, data)
|
|
325
|
+
return
|
|
326
|
+
outer_path = self._outer_file()
|
|
327
|
+
if not _os.path.lexists(outer_path):
|
|
328
|
+
# First write into a missing archive creates it ("x": never
|
|
329
|
+
# clobber a file that appeared in the meantime).
|
|
330
|
+
self._close_handle()
|
|
331
|
+
with _zipfile.ZipFile(
|
|
332
|
+
outer_path, "x", compression=_zipfile.ZIP_DEFLATED
|
|
333
|
+
) as archive:
|
|
334
|
+
archive.writestr(path, data)
|
|
335
|
+
return
|
|
336
|
+
if path in self.snapshot().infos:
|
|
337
|
+
# zipfile has no in-place entry update -- writestr()-ing an
|
|
338
|
+
# existing name just appends a duplicate. Overwriting an
|
|
339
|
+
# existing entry needs a full-archive rewrite.
|
|
340
|
+
self._rewrite(overwrite={path: data})
|
|
341
|
+
return
|
|
342
|
+
self._append(path, data)
|
|
343
|
+
|
|
344
|
+
def _append(self, path: str, data: bytes):
|
|
345
|
+
"""Add a new entry without risking the archive: an in-place "a"
|
|
346
|
+
append overwrites the central directory with the new entry's data
|
|
347
|
+
and writes a new one only on close, so dying in between left no
|
|
348
|
+
readable member. The archive is byte-copied (nothing recompressed)
|
|
349
|
+
to a temp file, appended to there, and swapped in by
|
|
350
|
+
`_replace_outer`."""
|
|
351
|
+
outer_path = _os.path.realpath(self._outer_file())
|
|
352
|
+
if not _zipfile.is_zipfile(outer_path):
|
|
353
|
+
raise _zipfile.BadZipFile(f"File is not a zip file: {outer_path!r}")
|
|
354
|
+
|
|
355
|
+
def fill(tmp):
|
|
356
|
+
with open(outer_path, "rb") as original:
|
|
357
|
+
_shutil.copyfileobj(original, tmp)
|
|
358
|
+
# zipfile only persists the central directory to disk when the
|
|
359
|
+
# *archive* (not just the entry) is closed.
|
|
360
|
+
with _zipfile.ZipFile(
|
|
361
|
+
tmp, "a", compression=_zipfile.ZIP_DEFLATED
|
|
362
|
+
) as archive:
|
|
363
|
+
archive.writestr(path, data)
|
|
364
|
+
|
|
365
|
+
self._replace_outer(fill)
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def _check_writable(outer_path: str) -> None:
|
|
369
|
+
if not _os.access(outer_path, _os.W_OK):
|
|
370
|
+
# Replacing swaps the file for a new one and so ignores its mode:
|
|
371
|
+
# a read-only archive is refused here, as an `open(..., "ab")`
|
|
372
|
+
# would refuse it.
|
|
373
|
+
raise PermissionError(
|
|
374
|
+
_errno.EACCES, _os.strerror(_errno.EACCES), outer_path
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _replace_outer(self, fill):
|
|
378
|
+
"""Build the new archive with `fill(tmp)` in a temp file next to the
|
|
379
|
+
archive -- resolving a symlink, so the link's target is what gets
|
|
380
|
+
replaced -- fsync it, keep the original's file mode, and atomically
|
|
381
|
+
replace the original (`os.replace`): a failure or crash at any point
|
|
382
|
+
leaves the original untouched and no temp file behind. Must be
|
|
383
|
+
called with `self._lock` held."""
|
|
384
|
+
outer_path = _os.path.realpath(self._outer_file())
|
|
385
|
+
self._check_writable(outer_path)
|
|
386
|
+
self._close_handle()
|
|
387
|
+
fd, tmp_name = _tempfile.mkstemp(
|
|
388
|
+
dir=_os.path.dirname(outer_path), prefix=".pathapi-zip-", suffix=".tmp"
|
|
389
|
+
)
|
|
390
|
+
try:
|
|
391
|
+
with _os.fdopen(fd, "w+b") as tmp:
|
|
392
|
+
fill(tmp)
|
|
393
|
+
tmp.flush()
|
|
394
|
+
_os.fsync(tmp.fileno())
|
|
395
|
+
_shutil.copymode(outer_path, tmp_name)
|
|
396
|
+
_os.replace(tmp_name, outer_path)
|
|
397
|
+
except BaseException:
|
|
398
|
+
try:
|
|
399
|
+
_os.chmod(tmp_name, _stat.S_IREAD | _stat.S_IWRITE)
|
|
400
|
+
_os.unlink(tmp_name)
|
|
401
|
+
except OSError:
|
|
402
|
+
pass
|
|
403
|
+
raise
|
|
404
|
+
|
|
405
|
+
def delete_members(self, names, *, keep_dir: "str | None" = None):
|
|
406
|
+
"""Drop every entry named in `names` in one rewrite. `keep_dir`
|
|
407
|
+
(`"a/b/"`) is written in the same rewrite as a directory entry: a
|
|
408
|
+
directory that exists only through its members must outlive them."""
|
|
409
|
+
with self._lock:
|
|
410
|
+
if self._batch is not None:
|
|
411
|
+
self._table()
|
|
412
|
+
for name in names:
|
|
413
|
+
self._detach(name)
|
|
414
|
+
self._batch.dirty = True
|
|
415
|
+
if keep_dir:
|
|
416
|
+
self._put(keep_dir, b"")
|
|
417
|
+
return
|
|
418
|
+
self._rewrite(
|
|
419
|
+
exclude=set(names),
|
|
420
|
+
overwrite={keep_dir: b""} if keep_dir else None,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def rename_member(
|
|
424
|
+
self,
|
|
425
|
+
old: str,
|
|
426
|
+
new: str,
|
|
427
|
+
*,
|
|
428
|
+
members=None,
|
|
429
|
+
replace=(),
|
|
430
|
+
keep_dir: "str | None" = None,
|
|
431
|
+
):
|
|
432
|
+
"""Rename `old` to `new`. `members` is an explicit raw-name mapping
|
|
433
|
+
for a directory rename, whose members may be spelled several ways
|
|
434
|
+
("./dir/x") and so cannot be found by prefix on one name. `replace`
|
|
435
|
+
names entries the rename overwrites, and `keep_dir` is a directory
|
|
436
|
+
entry written in the same rewrite (see `delete_members`)."""
|
|
437
|
+
with self._lock:
|
|
438
|
+
rename = dict(members) if members else {old: new}
|
|
439
|
+
if self._batch is not None:
|
|
440
|
+
self._rename_pending(set(replace), rename)
|
|
441
|
+
if keep_dir:
|
|
442
|
+
self._put(keep_dir, b"")
|
|
443
|
+
return
|
|
444
|
+
self._rewrite(
|
|
445
|
+
exclude=set(replace),
|
|
446
|
+
rename=rename,
|
|
447
|
+
overwrite={keep_dir: b""} if keep_dir else None,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def _rename_pending(self, exclude, rename) -> None:
|
|
451
|
+
"""`_rewrite()`'s exclusions and renames, applied to the open batch:
|
|
452
|
+
a renamed member keeps its place and replaces whatever has its new
|
|
453
|
+
name, and a `<name>/` key of `rename` also moves every member below
|
|
454
|
+
it. Costs the members moved, and, for a `<name>/` key, one pass over
|
|
455
|
+
the members."""
|
|
456
|
+
table = self._table()
|
|
457
|
+
for name in exclude:
|
|
458
|
+
self._detach(name)
|
|
459
|
+
moving = {
|
|
460
|
+
old: new for old, new in rename.items() if old in table and old != new
|
|
461
|
+
}
|
|
462
|
+
prefixes = {old: new for old, new in rename.items() if old.endswith("/")}
|
|
463
|
+
if prefixes:
|
|
464
|
+
for name in table:
|
|
465
|
+
if name in rename:
|
|
466
|
+
continue
|
|
467
|
+
for prefix, new_prefix in prefixes.items():
|
|
468
|
+
if name.startswith(prefix):
|
|
469
|
+
moving[name] = new_prefix + name[len(prefix) :]
|
|
470
|
+
break
|
|
471
|
+
entries = sorted((table[name].seq, new, name) for name, new in moving.items())
|
|
472
|
+
taken = [(new, self._detach(name)) for _seq, new, name in entries]
|
|
473
|
+
for new, entry in taken:
|
|
474
|
+
self._detach(new)
|
|
475
|
+
self._attach(
|
|
476
|
+
new,
|
|
477
|
+
_Pending(
|
|
478
|
+
_renamed_info(entry.info, new),
|
|
479
|
+
entry.origin,
|
|
480
|
+
entry.data,
|
|
481
|
+
entry.link,
|
|
482
|
+
entry.seq,
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
self._batch.dirty = True
|
|
486
|
+
|
|
487
|
+
def _rewrite(
|
|
488
|
+
self,
|
|
489
|
+
*,
|
|
490
|
+
exclude: "set[str]" = frozenset(),
|
|
491
|
+
rename: "dict[str, str] | None" = None,
|
|
492
|
+
overwrite: "dict[str, bytes] | None" = None,
|
|
493
|
+
):
|
|
494
|
+
"""Safely rewrite the whole archive: drop names in `exclude`,
|
|
495
|
+
rename per `rename` (old->new; a `<name>/` key also renames every
|
|
496
|
+
entry nested under that prefix), and set/add content for names in
|
|
497
|
+
`overwrite`. A renamed entry replaces any existing entry at its new
|
|
498
|
+
name (POSIX rename); otherwise duplicate names collapse to the last
|
|
499
|
+
one, which is the entry `zipfile` itself reads.
|
|
500
|
+
|
|
501
|
+
An entry that is only kept or renamed is copied as it lies in the
|
|
502
|
+
archive (`_copy_entry`): nothing is decompressed, so the cost is the
|
|
503
|
+
stored size, and an encrypted entry or one with a compression method
|
|
504
|
+
`zipfile` lacks survives. Every kept entry keeps its metadata, and the
|
|
505
|
+
archive keeps its comment, any bytes before the first member (a
|
|
506
|
+
zipapp shebang, a self-extractor stub) and its file mode. Written
|
|
507
|
+
through `_replace_outer`, so a crash mid-rewrite can't leave a
|
|
508
|
+
corrupt archive. Must be called with `self._lock` held (all public
|
|
509
|
+
mutators above already do)."""
|
|
510
|
+
rename = dict(rename or {})
|
|
511
|
+
overwrite = dict(overwrite or {})
|
|
512
|
+
remap = _remapper(exclude, rename)
|
|
513
|
+
outer_path = _os.path.realpath(self._outer_file())
|
|
514
|
+
|
|
515
|
+
def steps(src):
|
|
516
|
+
written = set()
|
|
517
|
+
for new_name, info, _renamed in _collapse(
|
|
518
|
+
((info.filename, info) for info in src.infolist()), remap
|
|
519
|
+
):
|
|
520
|
+
data = overwrite.pop(new_name, None)
|
|
521
|
+
if data is None:
|
|
522
|
+
data = overwrite.pop(info.filename, None)
|
|
523
|
+
if data is not None:
|
|
524
|
+
zinfo = _copy_zipinfo(info, new_name)
|
|
525
|
+
zinfo.date_time = _time.localtime()[:6]
|
|
526
|
+
yield new_name, None, zinfo, data
|
|
527
|
+
else:
|
|
528
|
+
yield new_name, info, None, None
|
|
529
|
+
written.add(new_name)
|
|
530
|
+
for name, data in overwrite.items():
|
|
531
|
+
if name not in written:
|
|
532
|
+
yield name, None, None, data
|
|
533
|
+
|
|
534
|
+
def fill(tmp):
|
|
535
|
+
with open(outer_path, "rb") as original:
|
|
536
|
+
with _zipfile.ZipFile(original, "r") as src:
|
|
537
|
+
self._emit(tmp, original, src, steps(src))
|
|
538
|
+
|
|
539
|
+
self._replace_outer(fill)
|
|
540
|
+
|
|
541
|
+
@staticmethod
|
|
542
|
+
def _emit(tmp, original, src, steps):
|
|
543
|
+
"""Write the entries `steps` yields as `(name, origin, zinfo, data)`
|
|
544
|
+
to `tmp`, after the bytes the archive `src` holds before its first
|
|
545
|
+
member: with `data`, a new entry (`zinfo`, else a default one);
|
|
546
|
+
without, the entry `origin` of `src`'s file, copied as it lies."""
|
|
547
|
+
infos = src.infolist()
|
|
548
|
+
# Where each entry's record ends: where the next one starts, or the
|
|
549
|
+
# central directory.
|
|
550
|
+
starts = sorted({info.header_offset for info in infos} | {src.start_dir})
|
|
551
|
+
ends = dict(zip(starts, starts[1:]))
|
|
552
|
+
original.seek(0)
|
|
553
|
+
_copy_range(original, tmp, starts[0])
|
|
554
|
+
|
|
555
|
+
with _zipfile.ZipFile(tmp, "w", _zipfile.ZIP_DEFLATED) as dst:
|
|
556
|
+
dst.comment = src.comment
|
|
557
|
+
for name, origin, zinfo, data in steps:
|
|
558
|
+
if data is not None:
|
|
559
|
+
dst.writestr(name if zinfo is None else zinfo, data)
|
|
560
|
+
elif not _copy_entry(
|
|
561
|
+
original, dst, origin, name, ends.get(origin.header_offset)
|
|
562
|
+
):
|
|
563
|
+
dst.writestr(_copy_zipinfo(origin, name), src.read(origin))
|