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
pathapi/mempath.py ADDED
@@ -0,0 +1,438 @@
1
+ from __future__ import annotations
2
+
3
+ import errno as _errno
4
+ import io
5
+ import posixpath as _posix
6
+ import time as _time
7
+ from io import IOBase
8
+ from urllib.parse import quote as _urlquote
9
+
10
+ from .path import Path, Pathname
11
+ from .protocols._errors import _os_error
12
+ from ._filestat import FileStat
13
+
14
+ __all__ = ["MemBytesIO", "MemFile", "MemPath", "MemPathBackend"]
15
+
16
+
17
+ class MemPathBackend(dict):
18
+ """Nested-dict storage backing one or more `MemPath` trees. A `dict`
19
+ value is a directory; a `bytearray` value is a file's content. Share
20
+ one instance across `MemPath`s (via `backend=`) to give them the same
21
+ virtual filesystem."""
22
+
23
+
24
+ class MemFile(bytearray):
25
+ """A file's content in a `MemPathBackend`, carrying its modification
26
+ time. A plain `bytearray` placed in a backend by hand still works; it
27
+ reports `st_mtime` 0."""
28
+
29
+ __slots__ = ("mtime",)
30
+
31
+ def __init__(self, *args) -> None:
32
+ super().__init__(*args)
33
+ self.mtime = 0.0
34
+ _touch(self)
35
+
36
+ def __reduce_ex__(self, protocol):
37
+ # `bytearray.__reduce_ex__` carries slot state only from 3.11; without
38
+ # this a copy or a pickle is re-stamped with the time it was made.
39
+ return _memfile, (bytes(self), self.mtime)
40
+
41
+
42
+ def _memfile(content: bytes, mtime: float) -> "MemFile":
43
+ """Rebuild a `MemFile` with the modification time it had."""
44
+ file = MemFile(content)
45
+ file.mtime = mtime
46
+ return file
47
+
48
+
49
+ def _touch(content) -> None:
50
+ """Advance `content`'s mtime -- strictly, even within one clock tick:
51
+ a sync quick check comparing (size, mtime) must see every write."""
52
+ try:
53
+ content.mtime = max(_time.time(), getattr(content, "mtime", 0.0) + 1e-6)
54
+ except AttributeError:
55
+ pass
56
+
57
+
58
+ class MemBytesIO(io.BytesIO):
59
+ """A `BytesIO` that writes its buffer back into the backing
60
+ `bytearray` (`dest`) on `flush()` and close, so `MemPath` files persist
61
+ across `open()` calls. With `append=True` every write lands at the end,
62
+ like `O_APPEND`, whatever the current position, and only the bytes this
63
+ handle wrote are added to `dest`, so two appenders both land."""
64
+
65
+ def __init__(self, dest: bytearray, *, append: bool = False) -> None:
66
+ self._bytes = dest
67
+ self._append = append
68
+ self._published = 0
69
+ super().__init__()
70
+ if append:
71
+ snapshot = bytes(dest)
72
+ super().write(snapshot)
73
+ self._published = len(snapshot)
74
+
75
+ def _publish(self) -> None:
76
+ # getvalue(), not seek(0);read(): a caller that seeks before
77
+ # closing (or opened in append mode, positioned at EOF) would
78
+ # otherwise lose everything before the current position.
79
+ content = self.getvalue()
80
+ if self._append:
81
+ # One extend() of the new bytes: a second appender's bytes
82
+ # already in `dest` stay, and a reader never sees a shorter file.
83
+ self._bytes.extend(content[self._published :])
84
+ self._published = len(content)
85
+ else:
86
+ # One slice assignment, not clear() then extend(): a reader
87
+ # never sees the file emptied in between.
88
+ self._bytes[:] = content
89
+ _touch(self._bytes)
90
+
91
+ def write(self, data) -> int:
92
+ if self._append and not self.closed:
93
+ self.seek(0, io.SEEK_END)
94
+ return super().write(data)
95
+
96
+ def writelines(self, lines) -> None:
97
+ # BytesIO.writelines bypasses an overridden write().
98
+ for line in lines:
99
+ self.write(line)
100
+
101
+ def flush(self) -> None:
102
+ # A real file's flush makes the written bytes visible to readers.
103
+ super().flush()
104
+ self._publish()
105
+
106
+ def close(self) -> None:
107
+ if not self.closed:
108
+ self._publish()
109
+ return super().close()
110
+
111
+
112
+ class _MemReader(io.BytesIO):
113
+ """A read-only snapshot of a file's content: `open("rb")` on a real
114
+ file cannot be written, and a plain `BytesIO` accepted writes that went
115
+ nowhere."""
116
+
117
+ def writable(self) -> bool:
118
+ if self.closed:
119
+ raise ValueError("I/O operation on closed file.")
120
+ return False
121
+
122
+ def write(self, data):
123
+ self.writable()
124
+ raise io.UnsupportedOperation("write")
125
+
126
+ def writelines(self, lines):
127
+ self.writable()
128
+ raise io.UnsupportedOperation("write")
129
+
130
+ def truncate(self, size=None):
131
+ self.writable()
132
+ raise io.UnsupportedOperation("truncate")
133
+
134
+
135
+ class MemPath(Path):
136
+ """In-memory `Path` implementation over nested dicts (see
137
+ `MemPathBackend`) -- a lightweight virtual filesystem for mocks, tests,
138
+ or transient storage, and the reference exemplar for Track A of
139
+ extending this library (subclassing `Path` directly; see
140
+ `docs/guides/extending.md`)."""
141
+
142
+ __slots__ = ("_backend", "_segments", "_normalized")
143
+
144
+ def __init__(
145
+ self, *segments: str | Pathname | Path, backend: MemPathBackend = None
146
+ ):
147
+ # Joined and normalized like `PurePosixPath`: empty and "." segments collapse and
148
+ # an absolute argument restarts the join (`MemPath("/root") / "/etc"` is "/etc").
149
+ path = ""
150
+ _backend = None
151
+ for segment in segments:
152
+ if isinstance(segment, MemPath):
153
+ text = segment.as_posix()
154
+ # The first MemPath argument names the filesystem, as the
155
+ # left operand does for `/`; a later one (often a pattern or
156
+ # a name built without a backend) only contributes its text.
157
+ if _backend is None:
158
+ _backend = segment.backend
159
+ elif isinstance(segment, Path):
160
+ raise NotImplementedError()
161
+ elif isinstance(segment, Pathname):
162
+ text = "/".join(segment.segments)
163
+ elif isinstance(segment, str):
164
+ text = segment
165
+ else:
166
+ raise TypeError(
167
+ "argument should be a str or a Pathname, "
168
+ f"not {type(segment).__name__!r}"
169
+ )
170
+ if text.startswith("/") or not path:
171
+ path = text
172
+ elif text:
173
+ path = f"{path}/{text}"
174
+ self._segments = self._parse(path)
175
+ # `is not None`, not truthiness: a fresh root's backend is an empty dict, which
176
+ # is falsy, and a join off it would get a disconnected new backend.
177
+ if _backend is not None and backend is None:
178
+ backend = _backend
179
+ self._backend = backend if backend is not None else MemPathBackend()
180
+ self._normalized = None
181
+
182
+ @staticmethod
183
+ def _parse(path: str) -> tuple:
184
+ """Segments of a joined path string: `("", "")` for the root,
185
+ `("", name, ...)` for another absolute path, `(name, ...)` for a
186
+ relative one and `()` for the empty path (so its `root` is "")."""
187
+ names = [name for name in path.split("/") if name and name != "."]
188
+ if path.startswith("/"):
189
+ return ("", *names) if names else ("", "")
190
+ return tuple(names)
191
+
192
+ def __repr__(self):
193
+ return "{}({!r})".format(type(self).__name__, self.as_posix())
194
+
195
+ def __str__(self) -> str:
196
+ return self.as_posix()
197
+
198
+ @property
199
+ def backend(self):
200
+ return self._backend
201
+
202
+ def _same_filesystem(self, other: "MemPath") -> bool:
203
+ # Equality ignores the backend; two trees are one filesystem only
204
+ # when they share the `MemPathBackend` instance.
205
+ return self._backend is other._backend
206
+
207
+ def _node_key(self):
208
+ # "a.txt", "/a.txt" and "/d/../a.txt" are one node. This reads no tree, so `..` is
209
+ # applied to the text; I/O applies it to the tree (`_parent_container`) and agrees
210
+ # wherever the path exists (`/missing/../a.txt` fails on I/O whatever it equals).
211
+ return self._backend, tuple(name for name in self.normalized if name)
212
+
213
+ @property
214
+ def normalized(self):
215
+ """The names of this path with `..` applied to the text and clamped
216
+ at the root; `[""]` for the root. `_node_key()` reads it."""
217
+ if self._normalized is None:
218
+ # Normalize against a virtual root ("/" + posix) so a ".."-escaping path is clamped
219
+ # at the root (posixpath.normpath("..") is ".."). The leading "/" is stripped first:
220
+ # normpath keeps an exactly double leading slash, which would turn "/" into "//".
221
+ posix = self.as_posix().lstrip("/")
222
+ self._normalized = _posix.normpath("/" + posix).removeprefix("/").split("/")
223
+ return self._normalized
224
+
225
+ @property
226
+ def segments(self):
227
+ return self._segments
228
+
229
+ @property
230
+ def parts(self):
231
+ return self.segments, self.backend
232
+
233
+ @property
234
+ def parent(self):
235
+ segments = self.segments
236
+ if not segments or segments == ("", ""):
237
+ return self
238
+ if len(segments) == 2 and segments[0] == "":
239
+ # "/a" -> "/": dropping the root gave the relative empty path.
240
+ return self.with_segments("", "")
241
+ return self.with_segments(*segments[:-1])
242
+
243
+ def relative_to(self, other, *, walk_up: bool = False):
244
+ """`self` below `other`, as a relative path on the same backend.
245
+
246
+ `other` is a `MemPath` of the same backend or a `str`. Names are
247
+ compared as written, with no `..` applied. `ValueError` when `self`
248
+ is not below `other`, when the two differ in being rooted, or when
249
+ the backends differ. With `walk_up`, a path beside `other` gets a
250
+ `..` per level climbed; a `..` in `other` cannot be walked."""
251
+ if isinstance(other, MemPath):
252
+ if other.backend is not self.backend:
253
+ raise ValueError(
254
+ f"{str(self)!r} and {str(other)!r} are on different backends"
255
+ )
256
+ else:
257
+ other = self.with_segments(other)
258
+ rooted = self.segments[:1] == ("",)
259
+ other_rooted = other.segments[:1] == ("",)
260
+ names = [name for name in self.segments if name]
261
+ other_names = [name for name in other.segments if name]
262
+ for step in range(len(other_names) + 1):
263
+ keep = len(other_names) - step
264
+ if rooted == other_rooted and names[:keep] == other_names[:keep]:
265
+ return self.with_segments(*[".."] * step, *names[keep:])
266
+ if not walk_up:
267
+ raise ValueError(
268
+ f"{str(self)!r} is not in the subpath of {str(other)!r}"
269
+ )
270
+ if keep and other_names[keep - 1] == "..":
271
+ raise ValueError(f"'..' segment in {str(other)!r} cannot be walked")
272
+ raise ValueError(f"{str(self)!r} and {str(other)!r} have different anchors")
273
+
274
+ def _cwd(self):
275
+ return self.with_segments("/")
276
+
277
+ def with_segments(self, *segments: str):
278
+ if all(isinstance(segment, str) for segment in segments):
279
+ # The `Pathname` protocol's spelling: segments joined with "/",
280
+ # a leading "" marking the root (`("", "a")` is "/a").
281
+ segments = ("/".join(segments),)
282
+ return type(self)(*segments, backend=self.backend)
283
+
284
+ def as_uri(self):
285
+ return f"mempath:{_urlquote(self.as_posix())}"
286
+
287
+ def _parent_container(self) -> tuple[dict[str, bytearray], str]:
288
+ """`(container, name)` of the node this path names; `name` is "" for
289
+ the root, whose container is the backend. `..` is applied to the tree:
290
+ the directory it leaves must exist, and the root is its own parent."""
291
+ containers = [self.backend]
292
+ names: list[str] = []
293
+ components = [name for name in self.segments if name]
294
+ for index, component in enumerate(components):
295
+ if component == "..":
296
+ if names:
297
+ names.pop()
298
+ containers.pop()
299
+ continue
300
+ if index == len(components) - 1:
301
+ return containers[-1], component
302
+ child = containers[-1].get(component)
303
+ if child is None:
304
+ raise _os_error(FileNotFoundError, _errno.ENOENT, self)
305
+ if not isinstance(child, dict):
306
+ # An ancestor names a file: without this the next step raises TypeError, which
307
+ # passes the OSError guards of stat()/exists()/is_dir(). NotADirectoryError is an
308
+ # OSError, as stdlib raises.
309
+ raise _os_error(
310
+ NotADirectoryError,
311
+ _errno.ENOTDIR,
312
+ self.with_segments(*names, component),
313
+ )
314
+ containers.append(child)
315
+ names.append(component)
316
+ if not names:
317
+ return self.backend, ""
318
+ return containers[-2], names[-1]
319
+
320
+ def _mkdir(self, mode: int):
321
+ parent, name = self._parent_container()
322
+ # setdefault is one atomic step: a separate check-then-set let two
323
+ # concurrent mkdir() calls both succeed, the second replacing a
324
+ # directory the first may already have populated.
325
+ new = {}
326
+ if not name or parent.setdefault(name, new) is not new:
327
+ raise _os_error(FileExistsError, _errno.EEXIST, self)
328
+
329
+ def rmdir(self):
330
+ parent, name = self._parent_container()
331
+ if not name:
332
+ # The root exists, so it is not "missing", and a tree cannot lose it.
333
+ raise OSError(
334
+ _errno.EBUSY, "The root of a MemPath tree cannot be removed", str(self)
335
+ )
336
+ content = parent.get(name)
337
+ if content is None:
338
+ raise _os_error(FileNotFoundError, _errno.ENOENT, self)
339
+ elif not isinstance(content, dict):
340
+ raise _os_error(NotADirectoryError, _errno.ENOTDIR, self)
341
+ elif len(content) != 0:
342
+ # pathlib raises OSError(ENOTEMPTY); FileExistsError (EEXIST)
343
+ # carried no errno and matched no caller's ENOTEMPTY check.
344
+ raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
345
+ parent.pop(name)
346
+
347
+ def unlink(self, missing_ok=False):
348
+ parent, name = self._parent_container()
349
+ if not name:
350
+ # The root exists and is a directory: missing_ok does not apply.
351
+ raise _os_error(IsADirectoryError, _errno.EISDIR, self)
352
+ content = parent.get(name)
353
+ if content is None:
354
+ if missing_ok:
355
+ return
356
+ raise _os_error(FileNotFoundError, _errno.ENOENT, self)
357
+ elif isinstance(content, dict):
358
+ raise _os_error(IsADirectoryError, _errno.EISDIR, self)
359
+ parent.pop(name)
360
+
361
+ def stat(self, *, follow_symlinks=True):
362
+ parent, name = self._parent_container()
363
+ if not name:
364
+ return FileStat(is_dir=True)
365
+
366
+ if name not in parent:
367
+ raise _os_error(FileNotFoundError, _errno.ENOENT, self)
368
+
369
+ content = parent[name]
370
+ is_dir = isinstance(content, dict)
371
+ # st_size was never set for files (always defaulted to 0), which
372
+ # silently broke any size-based checksum (e.g. PathSyncer's default
373
+ # usage pattern).
374
+ return FileStat(
375
+ is_dir=is_dir,
376
+ st_size=0 if is_dir else len(content),
377
+ st_mtime=getattr(content, "mtime", 0),
378
+ )
379
+
380
+ def _utime(self, *, atime=None, mtime=None) -> None:
381
+ # Only a file's modification time is kept: a directory (a plain dict)
382
+ # and a bytearray put in the backend by hand have nowhere to put one.
383
+ parent, name = self._parent_container()
384
+ content = parent.get(name) if name else parent
385
+ if content is None:
386
+ raise _os_error(FileNotFoundError, _errno.ENOENT, self)
387
+ if not isinstance(content, MemFile):
388
+ raise NotImplementedError("_utime() of a directory or a plain bytearray")
389
+ if mtime is not None:
390
+ content.mtime = float(mtime)
391
+
392
+ def iterdir(self):
393
+ parent, name = self._parent_container()
394
+ content = parent.get(name) if name else parent
395
+ if content is None:
396
+ raise _os_error(FileNotFoundError, _errno.ENOENT, self)
397
+ if not isinstance(content, dict):
398
+ raise _os_error(NotADirectoryError, _errno.ENOTDIR, self)
399
+ for c in list(content.keys()):
400
+ yield self.with_segments(*self.segments, c)
401
+
402
+ def _open(self, mode="r", buffering=-1) -> IOBase:
403
+ # mode contract: "r"/"w" are required; "x"/"a" are supported here
404
+ # as an extension. Anything else raises NotImplementedError.
405
+ parent, name = self._parent_container()
406
+ if not name:
407
+ # An empty name is the virtual root, which stat() reports as a
408
+ # directory. Without this guard "w"/"a" created a bogus ""
409
+ # entry in the backend and "r" claimed FileNotFoundError.
410
+ raise _os_error(IsADirectoryError, _errno.EISDIR, self)
411
+ if mode == "r":
412
+ if name not in parent:
413
+ raise _os_error(FileNotFoundError, _errno.ENOENT, self)
414
+ content = parent[name]
415
+ if isinstance(content, dict):
416
+ raise _os_error(IsADirectoryError, _errno.EISDIR, self)
417
+ return _MemReader(content)
418
+ if mode not in ("w", "x", "a"):
419
+ raise NotImplementedError(f"mode={mode!r}")
420
+ # Creation goes through setdefault, one atomic step: a separate
421
+ # "exists?" check followed by an assignment let two concurrent
422
+ # open("x") calls both succeed.
423
+ new = MemFile()
424
+ content = parent.setdefault(name, new)
425
+ if isinstance(content, dict):
426
+ # Truncating over a directory silently replaced the whole
427
+ # subtree with a file; stdlib raises IsADirectoryError.
428
+ raise _os_error(IsADirectoryError, _errno.EISDIR, self)
429
+ if mode == "x":
430
+ if content is not new:
431
+ raise _os_error(FileExistsError, _errno.EEXIST, self)
432
+ return MemBytesIO(content)
433
+ if mode == "w" and content is not new:
434
+ # Truncate the same file, as "w" does on disk: its mtime then
435
+ # still advances past the previous write's.
436
+ content.clear()
437
+ _touch(content)
438
+ return MemBytesIO(content, append=mode == "a")
@@ -0,0 +1,19 @@
1
+ """Object-oriented filesystem paths.
2
+
3
+ This module provides classes to represent abstract paths and concrete
4
+ paths with operations that have semantics appropriate for different
5
+ operating systems.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._pure import FsPathLike, P, PN, Pathname, PurePathLike
11
+ from ._path import Path, PathLike
12
+
13
+ __all__ = [
14
+ "FsPathLike",
15
+ "Path",
16
+ "PathLike",
17
+ "Pathname",
18
+ "PurePathLike",
19
+ ]
pathapi/path/_info.py ADDED
@@ -0,0 +1,10 @@
1
+ """`Path.info`'s object lives with the protocol that returns it; this name
2
+ stays importable and is the same module."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys as _sys
7
+
8
+ from ..protocols import _info
9
+
10
+ _sys.modules[__name__] = _info
pathapi/path/_path.py ADDED
@@ -0,0 +1,198 @@
1
+ """`Path`: a pathname with I/O, built from the protocols."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib as _pathlib
6
+ import sys as _sys
7
+ import types as _types
8
+ import typing as _ty
9
+
10
+ from ..protocols import (
11
+ BinaryOpen,
12
+ Chmod,
13
+ Chown,
14
+ Copy,
15
+ Create,
16
+ Hardlink,
17
+ Identity,
18
+ Listing,
19
+ Move,
20
+ Owned,
21
+ Readlink,
22
+ Remove,
23
+ Resolve,
24
+ Stat,
25
+ Symlink,
26
+ Utime,
27
+ )
28
+ from ._pure import Pathname
29
+
30
+ # Operations that must come from pathapi even when a concrete `pathlib`
31
+ # class precedes it in a subclass's MRO; `Path.__init_subclass__` applies
32
+ # them and says why.
33
+ _OPERATION_NAMES = (
34
+ "copy",
35
+ "move",
36
+ "copy_into",
37
+ "move_into",
38
+ "exists",
39
+ "rglob",
40
+ "read_text",
41
+ "write_text",
42
+ "symlink_to",
43
+ )
44
+
45
+
46
+ def _local_primitives() -> "dict[str, _ty.Callable]":
47
+ """The functions `LocalPath` defines, by name: what a class that mixes a
48
+ concrete stdlib path with `Path` but not `LocalPath` is given for its
49
+ local primitives (`stat`, `chmod`, `is_junction`, `is_mount`, `glob`,
50
+ `walk`, `_scandir`, `_symlink_to`, `_chown`, ...). Taken from the class
51
+ itself so the list cannot fall behind it. Empty while `LocalPath` is
52
+ still being created."""
53
+ module = _sys.modules.get(__name__.partition(".")[0] + ".fspath")
54
+ local = getattr(module, "LocalPath", None)
55
+ if local is None:
56
+ return {}
57
+ return {
58
+ name: value
59
+ for name, value in vars(local).items()
60
+ if isinstance(value, _types.FunctionType)
61
+ and not (name.startswith("__") and name.endswith("__"))
62
+ }
63
+
64
+
65
+ def _inherits_foreign_default(cls: type, name: str) -> bool:
66
+ """Whether `cls` gets `name` from stdlib `pathlib` or from this
67
+ library's own generic defaults (`Path`, `Pathname`, the protocols)
68
+ rather than from its own body or a mixin of its own."""
69
+ if name in vars(cls):
70
+ return False
71
+ owner = next((base for base in cls.__mro__[1:] if name in vars(base)), None)
72
+ if owner is None:
73
+ return True
74
+ module = getattr(owner, "__module__", "") or ""
75
+ package = __name__.partition(".")[0]
76
+ return (
77
+ module == "pathlib"
78
+ or module.startswith("pathlib.")
79
+ or module == package + ".path"
80
+ or module.startswith(package + ".path.")
81
+ or module == package + ".protocols"
82
+ or module.startswith(package + ".protocols.")
83
+ )
84
+
85
+
86
+ class Path(
87
+ Pathname,
88
+ Move,
89
+ Copy,
90
+ Identity,
91
+ Create,
92
+ Symlink,
93
+ Remove,
94
+ Listing,
95
+ Resolve,
96
+ Readlink,
97
+ Hardlink,
98
+ Owned,
99
+ Stat,
100
+ Chmod,
101
+ Chown,
102
+ Utime,
103
+ BinaryOpen,
104
+ ):
105
+ """Base class for manipulating paths with I/O."""
106
+
107
+ __slots__ = ()
108
+
109
+ def __init_subclass__(cls, **kwargs):
110
+ """Guarantee pathapi operation precedence in every subclass.
111
+
112
+ Concrete path classes are routinely built by mixing a `pathlib`
113
+ class with this one -- our own `LocalPath` does it, and the
114
+ documented downstream recipe (`class X(PosixPathname, Path)`) does
115
+ it transitively. Python's MRO then resolves a name to whichever
116
+ base declares it first, which for those classes can be `pathlib`
117
+ rather than `pathapi` -- and which one wins changes with the
118
+ interpreter version, because stdlib `pathlib` keeps gaining and
119
+ changing methods (see `_OPERATION_NAMES`).
120
+
121
+ Rather than making every downstream implementer rediscover this and
122
+ hand-write forwarding methods, re-assert our implementations here
123
+ for any subclass that would otherwise inherit a non-pathapi
124
+ one. A subclass (or an intermediate mixin) that defines the method
125
+ *itself* is always left alone -- this only displaces implementations
126
+ coming from outside this library.
127
+
128
+ `_OPERATION_NAMES` lists what is re-asserted, for two opposite
129
+ reasons. Newer stdlib overrides ours: 3.14's `copy` and `move` expect
130
+ the private `_copy_from` protocol, so such a class either raises
131
+ `AttributeError` on non-local backends or silently succeeds on local
132
+ ones with stdlib's metadata semantics (timestamps preserved, where
133
+ ours preserves `st_mode`), so mtime-based syncs converge on 3.14 and
134
+ not before; 3.14's `copy_into` and `move_into` call `copy()` and
135
+ `move()` with stdlib's keywords and defaults. Older stdlib lacks our
136
+ keywords: `exists(follow_symlinks=)` (3.12+), `read_text` and
137
+ `write_text(newline=)` (3.13+), `rglob`'s extensions, and
138
+ `symlink_to(force=)`, which no stdlib has. `replace` is not listed:
139
+ `LocalPath` keeps stdlib's `os.replace`, which `move()` uses to swap a
140
+ local file atomically.
141
+
142
+ A class that mixes a concrete stdlib path with `Path` but not
143
+ `LocalPath` is a local class too, and gets what `LocalPath` has for
144
+ each name it defines (`_local_primitives`) wherever stdlib or a
145
+ generic default would otherwise answer: the generic `is_junction()`
146
+ and `is_mount()` say False, so `rm(recursive=True)` would walk into a
147
+ junction, and stdlib's `stat()`, `chmod()` and `_scandir()` do not
148
+ take pathapi's arguments.
149
+ """
150
+ super().__init_subclass__(**kwargs)
151
+ primitives = _local_primitives() if issubclass(cls, _pathlib.Path) else {}
152
+ if primitives:
153
+ from ..fspath import _BaseFSPathname
154
+
155
+ for name, ours in primitives.items():
156
+ if name == "glob" and not issubclass(cls, _BaseFSPathname):
157
+ # LocalPath.glob needs the flavour helpers of
158
+ # _BaseFSPathname.
159
+ ours = Path.glob
160
+ if _inherits_foreign_default(cls, name):
161
+ setattr(cls, name, ours)
162
+ for name in _OPERATION_NAMES:
163
+ # A class that defines the operation in its OWN body is always
164
+ # authoritative -- never displace a deliberate override (this
165
+ # also covers `LocalPath.copy`/`move`'s explicit routing).
166
+ if name in vars(cls):
167
+ continue
168
+ # The owner is the base that supplies the implementation, not the module of
169
+ # the resolved function: a downstream mixin may define it in its own module.
170
+ owner = next((base for base in cls.__mro__[1:] if name in vars(base)), None)
171
+ if owner is None:
172
+ continue
173
+ # Only stdlib `pathlib` is displaced; a mixin, a user base or pathapi
174
+ # itself is deliberate and stays, which keeps this off third-party code.
175
+ owner_module = getattr(owner, "__module__", "") or ""
176
+ if owner_module != "pathlib" and not owner_module.startswith("pathlib."):
177
+ continue
178
+ ours = getattr(Path, name, None)
179
+ if ours is None:
180
+ continue
181
+ setattr(cls, name, ours)
182
+
183
+ def __new__(cls, *args: _ty.Any, **kwargs: _ty.Any) -> _ty.Self:
184
+ if cls is Path:
185
+ from ..fspath import LocalPath
186
+
187
+ # `LocalPath.__new__` is `pathlib.Path.__new__`, which parses `args` into
188
+ # `_drv/_root/_parts` before 3.12 (3.12+ does it in `PurePath.__init__`);
189
+ # `Pathname.__new__(cls)` would skip that and drop `args`.
190
+ return _ty.cast("_ty.Self", LocalPath.__new__(LocalPath, *args, **kwargs))
191
+ return Pathname.__new__(cls)
192
+
193
+ def is_hidden(self):
194
+ """Return True if the final path component is a hidden file/directory."""
195
+ return self.name.startswith(".")
196
+
197
+
198
+ PathLike = _ty.Union[str, Path]