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/_pack.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import posixpath
|
|
4
|
+
import shutil
|
|
5
|
+
import tarfile
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
import typing as _ty
|
|
9
|
+
import warnings
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
from .._names import _is_safe_name, is_windows_flavoured
|
|
13
|
+
from .._filestat import FileStat
|
|
14
|
+
|
|
15
|
+
if _ty.TYPE_CHECKING:
|
|
16
|
+
from ..path import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_format(name: str, peek: "_ty.Callable[[], bytes] | None" = None) -> str:
|
|
20
|
+
"""Detect "zip" vs "tar" from `name`'s extension, falling back to
|
|
21
|
+
magic-byte sniffing (`PK` header -> zip) via `peek()` -- called lazily,
|
|
22
|
+
only when the extension is inconclusive, so callers backed by a remote
|
|
23
|
+
Path don't pay for a round trip in the common case. Shared by
|
|
24
|
+
`unpack_archive` and `ArchivePath` so both use one detection policy."""
|
|
25
|
+
name_lower = name.lower()
|
|
26
|
+
if name_lower.endswith((".zip", ".jar")):
|
|
27
|
+
return "zip"
|
|
28
|
+
if name_lower.endswith((".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")):
|
|
29
|
+
return "tar"
|
|
30
|
+
magic = peek() if peek is not None else b""
|
|
31
|
+
return "zip" if magic.startswith(b"PK") else "tar"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _safe_member_parts(
|
|
35
|
+
name: str, *, windows: bool, rewrites: bool = True
|
|
36
|
+
) -> "list[str] | None":
|
|
37
|
+
"""Split archive member `name` into the parts to join onto the
|
|
38
|
+
extraction directory, or return None when the member must be skipped
|
|
39
|
+
because a part would leave it (`..`, and with `windows=True` a drive
|
|
40
|
+
such as `D:x` or the drive-relative `C:..`) or, with `windows=True` and
|
|
41
|
+
`rewrites`, because Windows would store a part under another name or send
|
|
42
|
+
it to a device (`nul`, `trail.`). `/` always splits; a backslash splits
|
|
43
|
+
only for a Windows destination, the only place it means "separator" -- on
|
|
44
|
+
POSIX it is an ordinary filename character, and splitting it
|
|
45
|
+
unconditionally turned one legitimate member into a directory plus a file
|
|
46
|
+
on every platform. Empty and `.` parts are dropped, so `/abs` and `./x`
|
|
47
|
+
stay inside."""
|
|
48
|
+
if windows:
|
|
49
|
+
name = name.replace("\\", "/")
|
|
50
|
+
parts = [p for p in name.split("/") if p not in ("", ".")]
|
|
51
|
+
if not parts or not all(_is_safe_name(p, windows, rewrites) for p in parts):
|
|
52
|
+
return None
|
|
53
|
+
return parts
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: In-memory size up to which an archive being built or read is buffered
|
|
57
|
+
#: before spilling to a temporary file.
|
|
58
|
+
_SPOOL_SIZE = 64 * 1024 * 1024
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Spool(tempfile.SpooledTemporaryFile):
|
|
62
|
+
"""A seekable in-memory-then-disk buffer. `SpooledTemporaryFile` only
|
|
63
|
+
gained `seekable()`/`readable()`/`writable()` in 3.11, and zipfile
|
|
64
|
+
probes them."""
|
|
65
|
+
|
|
66
|
+
def __init__(self):
|
|
67
|
+
super().__init__(max_size=_SPOOL_SIZE)
|
|
68
|
+
|
|
69
|
+
def seekable(self):
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
def readable(self):
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def writable(self):
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _archive_members(
|
|
80
|
+
src: "Path",
|
|
81
|
+
) -> "_ty.Iterator[tuple[Path, str, FileStat | None, bool]]":
|
|
82
|
+
"""Yield `(path, member name, listed stat, is a directory)` for every
|
|
83
|
+
entry below the directory `src`, each directory before what is in it and
|
|
84
|
+
the empty ones too. The name is built from the listed names while
|
|
85
|
+
descending, so it works for any `Path` (no `relative_to()`/`parts`,
|
|
86
|
+
which MemPath and URI paths do not provide in that form). Directories are
|
|
87
|
+
recognised without following symlinks, as `walk()` does; the stat is the
|
|
88
|
+
one that decided that, or None when it failed."""
|
|
89
|
+
pending = [(src, "")]
|
|
90
|
+
while pending:
|
|
91
|
+
directory, prefix = pending.pop()
|
|
92
|
+
subdirs = []
|
|
93
|
+
for child in directory.iterdir():
|
|
94
|
+
name = f"{prefix}{child.name}"
|
|
95
|
+
try:
|
|
96
|
+
stat = FileStat.from_path(child, follow_symlink=False)
|
|
97
|
+
is_dir = stat is not None and stat.is_dir()
|
|
98
|
+
except OSError:
|
|
99
|
+
stat, is_dir = None, False
|
|
100
|
+
if is_dir:
|
|
101
|
+
yield child, name, stat, True
|
|
102
|
+
subdirs.append((child, f"{name}/"))
|
|
103
|
+
else:
|
|
104
|
+
yield child, name, stat, False
|
|
105
|
+
pending.extend(reversed(subdirs))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _content_stat(path: "Path", listed: "FileStat | None") -> "FileStat | None":
|
|
109
|
+
"""The stat that describes what `path` holds: the listing's own for
|
|
110
|
+
anything but a link, else what the link resolves to. None when the backend
|
|
111
|
+
has none to give: a member then carries no time and a default mode."""
|
|
112
|
+
if listed is not None and not listed.is_symlink():
|
|
113
|
+
return listed
|
|
114
|
+
try:
|
|
115
|
+
return FileStat.from_path(path)
|
|
116
|
+
except (OSError, NotImplementedError):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
#: The earliest date a zip member can carry; also the date of one whose source
|
|
121
|
+
#: reports no modification time.
|
|
122
|
+
_ZIP_EPOCH = (1980, 1, 1, 0, 0, 0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _zip_date(stat: "FileStat | None") -> tuple:
|
|
126
|
+
mtime = getattr(stat, "st_mtime", 0)
|
|
127
|
+
if mtime:
|
|
128
|
+
try:
|
|
129
|
+
date = time.localtime(mtime)[:6]
|
|
130
|
+
except (OverflowError, OSError, ValueError):
|
|
131
|
+
return _ZIP_EPOCH
|
|
132
|
+
if 1980 <= date[0] <= 2107:
|
|
133
|
+
return date
|
|
134
|
+
return _ZIP_EPOCH
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _tar_info(
|
|
138
|
+
name: str, stat: "FileStat | None", *, directory: bool
|
|
139
|
+
) -> tarfile.TarInfo:
|
|
140
|
+
"""A member header carrying the source's modification time and permission
|
|
141
|
+
bits. A source that reports no mode (`mode_known` false, or no stat) gets
|
|
142
|
+
the usual 0o644 for a file and 0o755 for a directory, not the read-only
|
|
143
|
+
placeholder a backend invents."""
|
|
144
|
+
info = tarfile.TarInfo(name)
|
|
145
|
+
if directory:
|
|
146
|
+
info.type = tarfile.DIRTYPE
|
|
147
|
+
if stat is not None and getattr(stat, "mode_known", True) and stat.st_mode:
|
|
148
|
+
info.mode = stat.st_mode & 0o7777
|
|
149
|
+
else:
|
|
150
|
+
info.mode = 0o755 if directory else 0o644
|
|
151
|
+
info.mtime = int(getattr(stat, "st_mtime", 0) or 0)
|
|
152
|
+
return info
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _copy_chunks(src_f, dest_f) -> int:
|
|
156
|
+
"""Copy `src_f` to `dest_f` until the end; the number of bytes copied."""
|
|
157
|
+
total = 0
|
|
158
|
+
while True:
|
|
159
|
+
chunk = src_f.read(65536)
|
|
160
|
+
if not chunk:
|
|
161
|
+
break
|
|
162
|
+
dest_f.write(chunk)
|
|
163
|
+
total += len(chunk)
|
|
164
|
+
return total
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _TarLinks:
|
|
168
|
+
"""Where the members of one tar archive are by name, for resolving its
|
|
169
|
+
links. A symlink is read relative to its own directory and finds the last
|
|
170
|
+
member of that name; a hard link finds the last one before it."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, members: "list[tarfile.TarInfo]") -> None:
|
|
173
|
+
self._position = {}
|
|
174
|
+
self._named: "dict[str, list[int]]" = {}
|
|
175
|
+
self._members = members
|
|
176
|
+
for index, member in enumerate(members):
|
|
177
|
+
self._position[id(member)] = index
|
|
178
|
+
self._named.setdefault(posixpath.normpath(member.name), []).append(index)
|
|
179
|
+
|
|
180
|
+
def target(self, link: tarfile.TarInfo) -> "tarfile.TarInfo | None":
|
|
181
|
+
"""The regular-file member `link` names, through any chain of links,
|
|
182
|
+
or None when it names nothing, a directory or another non-file, or
|
|
183
|
+
leads back to a link already followed."""
|
|
184
|
+
seen = set()
|
|
185
|
+
current = link
|
|
186
|
+
while current.islnk() or current.issym():
|
|
187
|
+
if id(current) in seen:
|
|
188
|
+
return None
|
|
189
|
+
seen.add(id(current))
|
|
190
|
+
if current.issym():
|
|
191
|
+
name = "/".join(
|
|
192
|
+
filter(None, (posixpath.dirname(current.name), current.linkname))
|
|
193
|
+
)
|
|
194
|
+
limit = len(self._members)
|
|
195
|
+
else:
|
|
196
|
+
name = current.linkname
|
|
197
|
+
limit = self._position.get(id(current), 0)
|
|
198
|
+
before = [
|
|
199
|
+
index
|
|
200
|
+
for index in self._named.get(posixpath.normpath(name), ())
|
|
201
|
+
if index < limit
|
|
202
|
+
]
|
|
203
|
+
if not before:
|
|
204
|
+
return None
|
|
205
|
+
current = self._members[before[-1]]
|
|
206
|
+
return current if current.isreg() else None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def make_archive(src: Path, format: str, target: Path) -> None:
|
|
210
|
+
"""Create an archive file from `src` at `target`.
|
|
211
|
+
|
|
212
|
+
Supports format='zip' and format='tar'.
|
|
213
|
+
Operations run stream-first to support any Path implementation, for
|
|
214
|
+
`src` as well as `target`. The archive is built in a temporary buffer
|
|
215
|
+
and written to `target` only once complete, so a failure leaves an
|
|
216
|
+
existing `target` untouched. Zip members always carry zip64 headers,
|
|
217
|
+
so a member over 2 GiB is stored instead of failing after the fact.
|
|
218
|
+
|
|
219
|
+
A directory is a member of its own, empty ones included. A member carries
|
|
220
|
+
the modification time its source reports (a zip date from 1980, a tar time
|
|
221
|
+
in whole seconds; none reported gives 1980-01-01 or the epoch) and, in a
|
|
222
|
+
tar, the permission bits it reports (0o644 for a file and 0o755 for a
|
|
223
|
+
directory when it reports none). A tar member's size is the number of
|
|
224
|
+
bytes read from the source, not what `stat()` says: a backend may not know
|
|
225
|
+
it, or count it differently.
|
|
226
|
+
"""
|
|
227
|
+
if format not in ("zip", "tar"):
|
|
228
|
+
raise ValueError(f"Unsupported format: {format}")
|
|
229
|
+
|
|
230
|
+
if src.is_file():
|
|
231
|
+
members = [(src, src.name, None, False)]
|
|
232
|
+
elif src.is_dir():
|
|
233
|
+
members = _archive_members(src)
|
|
234
|
+
else:
|
|
235
|
+
raise FileNotFoundError(f"No such file or directory: {str(src)!r}")
|
|
236
|
+
|
|
237
|
+
with _Spool() as buffer:
|
|
238
|
+
if format == "zip":
|
|
239
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
240
|
+
for file_path, arcname, listed, is_dir in members:
|
|
241
|
+
stat = listed if is_dir else _content_stat(file_path, listed)
|
|
242
|
+
if is_dir:
|
|
243
|
+
info = zipfile.ZipInfo(f"{arcname}/", _zip_date(stat))
|
|
244
|
+
# drwxr-xr-x, with the MS-DOS directory flag.
|
|
245
|
+
info.external_attr = (0o40755 << 16) | 0x10
|
|
246
|
+
archive.writestr(info, b"")
|
|
247
|
+
continue
|
|
248
|
+
info = zipfile.ZipInfo(arcname, _zip_date(stat))
|
|
249
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
250
|
+
# force_zip64: the size is unknown when the entry opens,
|
|
251
|
+
# and without it zipfile raises once 2 GiB were written.
|
|
252
|
+
with archive.open(info, "w", force_zip64=True) as dest_f:
|
|
253
|
+
with file_path.open("rb") as src_f:
|
|
254
|
+
_copy_chunks(src_f, dest_f)
|
|
255
|
+
else:
|
|
256
|
+
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
|
257
|
+
for file_path, arcname, listed, is_dir in members:
|
|
258
|
+
if is_dir:
|
|
259
|
+
archive.addfile(_tar_info(arcname, listed, directory=True))
|
|
260
|
+
continue
|
|
261
|
+
info = _tar_info(
|
|
262
|
+
arcname, _content_stat(file_path, listed), directory=False
|
|
263
|
+
)
|
|
264
|
+
# The header comes first and holds the size, so the
|
|
265
|
+
# content is read once into a buffer to count it.
|
|
266
|
+
with _Spool() as content:
|
|
267
|
+
with file_path.open("rb") as src_f:
|
|
268
|
+
info.size = _copy_chunks(src_f, content)
|
|
269
|
+
content.seek(0)
|
|
270
|
+
archive.addfile(info, content)
|
|
271
|
+
buffer.seek(0)
|
|
272
|
+
with target.open("wb") as out_f:
|
|
273
|
+
shutil.copyfileobj(buffer, out_f)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def unpack_archive(archive: Path, dest: Path) -> None:
|
|
277
|
+
"""Extract `archive` file into `dest` directory.
|
|
278
|
+
|
|
279
|
+
Supports format detection from filename.
|
|
280
|
+
Operations run stream-first to support any Path implementation.
|
|
281
|
+
Members whose name would land outside `dest` (a `..` part, or on a
|
|
282
|
+
Windows-flavoured `dest` a drive such as `D:x` or `C:..`) are skipped.
|
|
283
|
+
On a Windows-flavoured `dest` so is a member with a part Windows would
|
|
284
|
+
store under another name (a trailing dot or space) or send to a device
|
|
285
|
+
(`NUL`, `CON`, `COM1`, ...), with a `UserWarning` naming it.
|
|
286
|
+
A non-seekable archive stream (e.g. an HTTP response) is buffered first.
|
|
287
|
+
A tar hard link, or a symlink to a regular file inside the archive, is
|
|
288
|
+
extracted as a regular file with the target member's content (no link
|
|
289
|
+
is created, so `dest` needs no symlink support); a link that does not
|
|
290
|
+
resolve to such a member (it names nothing, a directory, or itself
|
|
291
|
+
through other links) is skipped with a `UserWarning`.
|
|
292
|
+
"""
|
|
293
|
+
if not dest.exists():
|
|
294
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
|
|
296
|
+
def _peek() -> bytes:
|
|
297
|
+
try:
|
|
298
|
+
with archive.open("rb") as f:
|
|
299
|
+
return f.read(4)
|
|
300
|
+
except Exception:
|
|
301
|
+
return b"PK" # can't sniff -- preserve the historical "assume zip" default
|
|
302
|
+
|
|
303
|
+
is_zip = detect_format(archive.name, _peek) == "zip"
|
|
304
|
+
windows = is_windows_flavoured(dest)
|
|
305
|
+
|
|
306
|
+
def member_parts(filename: str) -> "list[str] | None":
|
|
307
|
+
parts = _safe_member_parts(filename, windows=windows)
|
|
308
|
+
if (
|
|
309
|
+
parts is None
|
|
310
|
+
and windows
|
|
311
|
+
and _safe_member_parts(filename, windows=True, rewrites=False) is not None
|
|
312
|
+
):
|
|
313
|
+
warnings.warn(
|
|
314
|
+
f"unpack_archive: skipped member {filename!r}: Windows would "
|
|
315
|
+
"store it under another name or send it to a device",
|
|
316
|
+
UserWarning,
|
|
317
|
+
stacklevel=3,
|
|
318
|
+
)
|
|
319
|
+
return parts
|
|
320
|
+
|
|
321
|
+
with archive.open("rb") as raw_in, _Spool() as spool:
|
|
322
|
+
in_f = raw_in
|
|
323
|
+
seekable = getattr(raw_in, "seekable", None)
|
|
324
|
+
if seekable is None or not seekable():
|
|
325
|
+
# zipfile seeks to the central directory, and tarfile's "r:*"
|
|
326
|
+
# probe seeks back: neither works over a network stream.
|
|
327
|
+
shutil.copyfileobj(raw_in, spool)
|
|
328
|
+
spool.seek(0)
|
|
329
|
+
in_f = spool
|
|
330
|
+
if is_zip:
|
|
331
|
+
with zipfile.ZipFile(in_f) as zip_ref:
|
|
332
|
+
for member in zip_ref.infolist():
|
|
333
|
+
filename = member.filename
|
|
334
|
+
parts = member_parts(filename)
|
|
335
|
+
if parts is None:
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
target_path = dest
|
|
339
|
+
for part in parts:
|
|
340
|
+
target_path = target_path / part
|
|
341
|
+
|
|
342
|
+
if member.is_dir() or filename.endswith("/"):
|
|
343
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
else:
|
|
345
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
with target_path.open("wb") as out_f:
|
|
347
|
+
with zip_ref.open(member) as member_f:
|
|
348
|
+
_copy_chunks(member_f, out_f)
|
|
349
|
+
else:
|
|
350
|
+
with tarfile.open(fileobj=in_f, mode="r") as tar_ref:
|
|
351
|
+
members = tar_ref.getmembers()
|
|
352
|
+
links = None
|
|
353
|
+
for member in members:
|
|
354
|
+
filename = member.name
|
|
355
|
+
parts = member_parts(filename)
|
|
356
|
+
if parts is None:
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
target_path = dest
|
|
360
|
+
for part in parts:
|
|
361
|
+
target_path = target_path / part
|
|
362
|
+
|
|
363
|
+
if member.isdir():
|
|
364
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
365
|
+
elif member.isfile():
|
|
366
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
367
|
+
with target_path.open("wb") as out_f:
|
|
368
|
+
member_f = tar_ref.extractfile(member)
|
|
369
|
+
if member_f is not None:
|
|
370
|
+
_copy_chunks(member_f, out_f)
|
|
371
|
+
elif member.islnk() or member.issym():
|
|
372
|
+
# The member a link names inside the archive, never the filesystem, resolved one hop
|
|
373
|
+
# at a time: `extractfile()` follows a chain by recursion and a link that names
|
|
374
|
+
# itself never ends.
|
|
375
|
+
if links is None:
|
|
376
|
+
links = _TarLinks(members)
|
|
377
|
+
target_member = links.target(member)
|
|
378
|
+
member_f = (
|
|
379
|
+
None
|
|
380
|
+
if target_member is None
|
|
381
|
+
else tar_ref.extractfile(target_member)
|
|
382
|
+
)
|
|
383
|
+
if member_f is None:
|
|
384
|
+
warnings.warn(
|
|
385
|
+
f"unpack_archive: skipped link {filename!r} -> "
|
|
386
|
+
f"{member.linkname!r}: not a regular file in "
|
|
387
|
+
"the archive",
|
|
388
|
+
UserWarning,
|
|
389
|
+
stacklevel=2,
|
|
390
|
+
)
|
|
391
|
+
continue
|
|
392
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
393
|
+
with member_f, target_path.open("wb") as out_f:
|
|
394
|
+
_copy_chunks(member_f, out_f)
|