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/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")
|
pathapi/path/__init__.py
ADDED
|
@@ -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
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]
|