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
@@ -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))