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