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,272 @@
1
+ from __future__ import annotations
2
+
3
+ import stat as _stat
4
+ import struct as _struct
5
+ import tempfile as _tempfile
6
+ import time as _time
7
+ import zipfile as _zipfile
8
+
9
+ from ._backend import MEMBER_SPOOL_BYTES
10
+ from ._snapshot import NO_LINK as _NO_LINK
11
+
12
+ _ZIP64_EXTRA_ID = 0x0001
13
+
14
+
15
+ def _strip_zip64_extra(extra: bytes) -> bytes:
16
+ """Drop zip64 extended-information records from a raw extra field.
17
+ `zipfile` writes its own when an entry needs them; a copied one would
18
+ carry the old sizes/offsets and conflict with the rewritten entry."""
19
+ kept = bytearray()
20
+ offset = 0
21
+ while offset + 4 <= len(extra):
22
+ header_id, size = _struct.unpack("<HH", extra[offset : offset + 4])
23
+ end = offset + 4 + size
24
+ if header_id != _ZIP64_EXTRA_ID:
25
+ kept += extra[offset:end]
26
+ offset = end
27
+ return bytes(kept)
28
+
29
+
30
+ def _copy_zipinfo(info: _zipfile.ZipInfo, name: str) -> _zipfile.ZipInfo:
31
+ """A fresh `ZipInfo` for `name` carrying `info`'s member metadata
32
+ (timestamp, compression, permissions, comment, extra) -- the fields
33
+ `writestr()` would otherwise reset. Sizes, CRC and offsets are left for
34
+ `writestr()` to compute."""
35
+ copied = _zipfile.ZipInfo(name, date_time=info.date_time)
36
+ copied.compress_type = info.compress_type
37
+ copied.comment = info.comment
38
+ copied.extra = _strip_zip64_extra(info.extra)
39
+ copied.create_system = info.create_system
40
+ copied.internal_attr = info.internal_attr
41
+ copied.external_attr = info.external_attr
42
+ return copied
43
+
44
+
45
+ def _member_mode(info: _zipfile.ZipInfo) -> "int | None":
46
+ """The Unix mode stored for `info`, or None when there is none: only a
47
+ Unix-made entry (`create_system == 3`) carries one, in the high 16 bits
48
+ of `external_attr`. A special type other than a symlink (which
49
+ `_ZipBackend._stat_of` reports as one) is not modelled and reports no
50
+ mode either."""
51
+ mode = info.external_attr >> 16 if info.create_system == 3 else 0
52
+ if not _stat.S_IMODE(mode):
53
+ return None
54
+ kind = _stat.S_IFMT(mode)
55
+ if info.filename.endswith("/"):
56
+ kind = _stat.S_IFDIR
57
+ elif kind == 0:
58
+ kind = _stat.S_IFREG
59
+ if kind not in (_stat.S_IFREG, _stat.S_IFDIR):
60
+ return None
61
+ return kind | _stat.S_IMODE(mode)
62
+
63
+
64
+ _MAX_LINK_TEXT = 4096
65
+ """The longest text a zip link member may store (`PATH_MAX`); a longer one is
66
+ a link whose text cannot be read."""
67
+
68
+
69
+ def _is_link(info: _zipfile.ZipInfo) -> bool:
70
+ """Whether `info` is a symbolic link: an entry made on Unix whose mode
71
+ says so. Its data is the text of the link."""
72
+ return (
73
+ info.create_system == 3
74
+ and not info.filename.endswith("/")
75
+ and _stat.S_ISLNK(info.external_attr >> 16)
76
+ )
77
+
78
+
79
+ _COPY_CHUNK = 1024 * 1024
80
+ _LOCAL_HEADER = _struct.Struct("<4s2B4HL2L2H")
81
+
82
+
83
+ def _copy_range(source, target, length: int) -> None:
84
+ """Copy `length` bytes from `source` to `target` in chunks."""
85
+ while length > 0:
86
+ chunk = source.read(min(length, _COPY_CHUNK))
87
+ if not chunk:
88
+ raise _zipfile.BadZipFile("archive ended inside an entry")
89
+ target.write(chunk)
90
+ length -= len(chunk)
91
+
92
+
93
+ def _copy_entry(original, dst, info, new_name: str, end: "int | None") -> bool:
94
+ """Append the entry `info` to the archive being written by `dst` under
95
+ `new_name`, copying its compressed bytes as they are: the local header, the
96
+ data and any data descriptor lie between `info.header_offset` and `end`
97
+ (where the next record starts). Only a renamed entry, or one whose name
98
+ the central directory would write in another encoding than the local
99
+ header, gets a new local header. False, with nothing written, when the
100
+ record is not laid out as its directory says; the caller then recompresses
101
+ it."""
102
+ start = info.header_offset
103
+ if end is None or start < 0 or end <= start:
104
+ return False
105
+ original.seek(start)
106
+ header = original.read(_LOCAL_HEADER.size)
107
+ if len(header) < _LOCAL_HEADER.size:
108
+ return False
109
+ fields = _LOCAL_HEADER.unpack(header)
110
+ header_size = _LOCAL_HEADER.size + fields[10] + fields[11]
111
+ if fields[0] != b"PK\x03\x04" or start + header_size + info.compress_size > end:
112
+ return False
113
+ verbatim = new_name == info.filename and (
114
+ info.filename.isascii() or info.flag_bits & 0x800
115
+ )
116
+ if not verbatim and (
117
+ info.flag_bits & 0x08
118
+ and max(info.file_size, info.compress_size) > _zipfile.ZIP64_LIMIT
119
+ ):
120
+ return False
121
+ copied = _copy_zipinfo(info, new_name)
122
+ for name in (
123
+ "create_version",
124
+ "extract_version",
125
+ "reserved",
126
+ "flag_bits",
127
+ "volume",
128
+ "CRC",
129
+ "compress_size",
130
+ "file_size",
131
+ ):
132
+ setattr(copied, name, getattr(info, name))
133
+ out = dst.fp
134
+ out.seek(dst.start_dir)
135
+ copied.header_offset = out.tell()
136
+ if verbatim:
137
+ original.seek(start)
138
+ _copy_range(original, out, end - start)
139
+ else:
140
+ out.write(copied.FileHeader())
141
+ original.seek(start + header_size)
142
+ _copy_range(original, out, end - start - header_size)
143
+ dst.start_dir = out.tell()
144
+ dst.filelist.append(copied)
145
+ dst.NameToInfo[copied.filename] = copied
146
+ return True
147
+
148
+
149
+ def _remapper(exclude, rename):
150
+ """`remap(name) -> (new name | None, renamed)` for a rewrite: a name in
151
+ `exclude` goes, one in `rename` takes its new name, and a `<name>/` key of
152
+ `rename` also renames every entry nested under that prefix."""
153
+ prefixes = {old: new for old, new in rename.items() if old.endswith("/")}
154
+
155
+ def remap(name: str) -> "tuple[str | None, bool]":
156
+ if name in exclude:
157
+ return None, False
158
+ if name in rename:
159
+ return rename[name], True
160
+ for old_prefix, new_prefix in prefixes.items():
161
+ if name.startswith(old_prefix):
162
+ return new_prefix + name[len(old_prefix) :], True
163
+ return name, False
164
+
165
+ return remap
166
+
167
+
168
+ def _collapse(items, remap):
169
+ """`[(new name, payload, renamed)]` for the `(name, payload)` pairs of an
170
+ archive after `remap`, in archive order. A renamed entry replaces any
171
+ entry at its new name (POSIX rename); otherwise the last of several
172
+ entries with one name is the one `zipfile` reads, and the others go."""
173
+ plan = []
174
+ winner = {}
175
+ for name, payload in items:
176
+ new_name, renamed = remap(name)
177
+ if new_name is None:
178
+ continue
179
+ previous = winner.get(new_name)
180
+ if previous is None or renamed or not plan[previous][2]:
181
+ winner[new_name] = len(plan)
182
+ plan.append((new_name, payload, renamed))
183
+ return [step for index, step in enumerate(plan) if winner[step[0]] == index]
184
+
185
+
186
+ def _now() -> "tuple[int, ...]":
187
+ """The current local time as a zip stores it: whole even seconds."""
188
+ now = _time.localtime()[:6]
189
+ return (*now[:5], now[5] & ~1)
190
+
191
+
192
+ def _new_info(name: str, size: int) -> _zipfile.ZipInfo:
193
+ """The `ZipInfo` `ZipFile.writestr(name, data)` would give a new entry of
194
+ `size` bytes."""
195
+ info = _zipfile.ZipInfo(name, date_time=_now())
196
+ info.compress_type = _zipfile.ZIP_DEFLATED
197
+ if info.filename.endswith("/"):
198
+ info.external_attr = (0o40775 << 16) | 0x10
199
+ else:
200
+ info.external_attr = 0o600 << 16
201
+ info.file_size = size
202
+ return info
203
+
204
+
205
+ def _renamed_info(info: _zipfile.ZipInfo, name: str) -> _zipfile.ZipInfo:
206
+ """`_copy_zipinfo()` that also keeps the size a listing reports."""
207
+ copied = _copy_zipinfo(info, name)
208
+ copied.file_size = info.file_size
209
+ return copied
210
+
211
+
212
+ class _Scratch:
213
+ """The data a batch holds for members written in it: in memory up to
214
+ `MEMBER_SPOOL_BYTES` altogether, in an unnamed temporary file past that."""
215
+
216
+ __slots__ = ("_file", "_end")
217
+
218
+ def __init__(self):
219
+ self._file = _tempfile.SpooledTemporaryFile(max_size=MEMBER_SPOOL_BYTES)
220
+ self._end = 0
221
+
222
+ def add(self, data: bytes) -> "tuple[int, int]":
223
+ self._file.seek(self._end)
224
+ self._file.write(data)
225
+ reference = (self._end, len(data))
226
+ self._end += len(data)
227
+ return reference
228
+
229
+ def get(self, reference: "tuple[int, int]") -> bytes:
230
+ offset, length = reference
231
+ self._file.seek(offset)
232
+ return self._file.read(length)
233
+
234
+ def close(self) -> None:
235
+ self._file.close()
236
+
237
+
238
+ class _Pending:
239
+ """One member of a batch: the `ZipInfo` a listing reports for it, the
240
+ entry of the archive file whose stored bytes it is (`origin`, None for a
241
+ member written in the batch) and where the written bytes are (`data`).
242
+ `link` is the text of a link member (None when unreadable), else
243
+ `_NO_LINK`. `seq` is its place in the archive: a renamed member keeps it,
244
+ a new one comes last."""
245
+
246
+ __slots__ = ("info", "origin", "data", "link", "seq")
247
+
248
+ def __init__(self, info, origin, data, link=_NO_LINK, seq=0):
249
+ self.info = info
250
+ self.origin = origin
251
+ self.data = data
252
+ self.link = link
253
+ self.seq = seq
254
+
255
+
256
+ class _Batch:
257
+ """What a batch changed. `table` maps each member's name to its `_Pending`
258
+ and is read from the archive on the first change; `signature` is the file
259
+ it was read from (None: there was none), checked again before the archive
260
+ is replaced. `snapshot` is the member list derived from the table, kept up
261
+ to date change by change once it exists; `next_seq` is the place of the
262
+ next new member."""
263
+
264
+ __slots__ = ("table", "signature", "scratch", "dirty", "snapshot", "next_seq")
265
+
266
+ def __init__(self):
267
+ self.table = None
268
+ self.signature = None
269
+ self.scratch = _Scratch()
270
+ self.dirty = False
271
+ self.snapshot = None
272
+ self.next_seq = 0
pathapi/checksum.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib as _hashlib
4
+
5
+ __all__ = ["md5", "native", "sha256", "stream"]
6
+
7
+ from .protocols import BinaryOpen as _BinaryOpen
8
+ from .protocols import NativeChecksum as _NativeChecksum
9
+ from .protocols import supports as _supports
10
+
11
+
12
+ def native(
13
+ path: "_NativeChecksum | _BinaryOpen", algorithm: str = "md5"
14
+ ) -> "str | None":
15
+ """Try `path`'s backend-native `NativeChecksum.checksum()` (see
16
+ `protocols/checksum.py`); return `None` if `path` doesn't implement the
17
+ protocol at all, or if it does but can't produce `algorithm` (raises
18
+ `NotImplementedError` -- the protocol's documented contract for
19
+ "unsupported", never a wrong-algorithm value). Never raises for either
20
+ of those two cases; any other exception the backend raises propagates.
21
+ """
22
+ if not _supports(path, _NativeChecksum):
23
+ return None
24
+ checksum = getattr(path, "checksum", None)
25
+ if checksum is None:
26
+ return None
27
+ try:
28
+ return checksum(algorithm)
29
+ except NotImplementedError:
30
+ return None
31
+
32
+
33
+ def md5(path: "_BinaryOpen", chunk_size: int = 65536) -> str:
34
+ """Calculate MD5 checksum of the file at `path`."""
35
+ return stream(path, "md5", chunk_size)
36
+
37
+
38
+ def sha256(path: "_BinaryOpen", chunk_size: int = 65536) -> str:
39
+ """Calculate SHA-256 checksum of the file at `path`."""
40
+ return stream(path, "sha256", chunk_size)
41
+
42
+
43
+ def stream(path: "_BinaryOpen", algorithm: str = "md5", chunk_size: int = 65536) -> str:
44
+ """Streaming checksum for an arbitrary `hashlib` `algorithm` name (the
45
+ generic form of `md5`/`sha256` above, used where the algorithm is a
46
+ runtime parameter rather than fixed at the call site -- e.g.
47
+ `PathSyncer`'s streaming fallback, which must match whatever algorithm
48
+ a native checksum attempt was made under).
49
+
50
+ The digest compares content; it is not a security use, so it is created
51
+ with `usedforsecurity=False` -- on a FIPS-mode host `hashlib.md5()`
52
+ otherwise raises ValueError.
53
+ """
54
+ h = _hashlib.new(algorithm, usedforsecurity=False)
55
+ with path.open("rb") as f:
56
+ while True:
57
+ chunk = f.read(chunk_size)
58
+ if not chunk:
59
+ break
60
+ h.update(chunk)
61
+ return h.hexdigest()
pathapi/exceptions.py ADDED
@@ -0,0 +1,22 @@
1
+ """The exceptions `pathapi` defines.
2
+
3
+ A failure of the file system or of a backend is the `OSError` type that
4
+ `pathlib` raises for the same condition (`FileNotFoundError`,
5
+ `PermissionError`, ...); this module holds what has no such type. A bad
6
+ argument is a plain `ValueError` or `TypeError`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = ["NonRelativePatternError", "PathError"]
12
+
13
+
14
+ class PathError(Exception):
15
+ """Base class of the exceptions `pathapi` defines for a failure at
16
+ run time that no builtin describes."""
17
+
18
+
19
+ class NonRelativePatternError(PathError, NotImplementedError, ValueError):
20
+ """An absolute or anchored glob pattern. pathlib raises
21
+ `NotImplementedError("Non-relative patterns are unsupported")`; this is
22
+ also a `ValueError`, so either `except` clause catches it."""