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/path/_pure.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""The pure pathname: `Pathname`, its parents and the name rules it follows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc as _abc
|
|
6
|
+
import operator as _operator
|
|
7
|
+
import os as _os
|
|
8
|
+
import re as _re
|
|
9
|
+
import sys as _sys
|
|
10
|
+
import typing as _ty
|
|
11
|
+
|
|
12
|
+
from .. import _stubs
|
|
13
|
+
from .. import glob as _glob
|
|
14
|
+
|
|
15
|
+
if _ty.TYPE_CHECKING:
|
|
16
|
+
from ._path import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
P = _ty.TypeVar("P", bound="Path")
|
|
20
|
+
PN = _ty.TypeVar("PN", bound="Pathname")
|
|
21
|
+
|
|
22
|
+
_P = _ty.TypeVar("_P")
|
|
23
|
+
_F = _ty.TypeVar("_F", bound=_ty.Callable[..., _ty.Any])
|
|
24
|
+
|
|
25
|
+
if _ty.TYPE_CHECKING:
|
|
26
|
+
|
|
27
|
+
def _abstract(method: _F) -> _F:
|
|
28
|
+
# `Path(...)` builds a LocalPath, so type checkers must read the bare
|
|
29
|
+
# class as instantiable; at run time ABCMeta still refuses a subclass
|
|
30
|
+
# that leaves one of these out.
|
|
31
|
+
return method
|
|
32
|
+
|
|
33
|
+
else:
|
|
34
|
+
_abstract = _abc.abstractmethod
|
|
35
|
+
|
|
36
|
+
# 3.13 refuses an empty stem on a name that has a suffix (`with_stem("")`).
|
|
37
|
+
_STEM_KEEPS_SUFFIX = _sys.version_info >= (3, 13)
|
|
38
|
+
|
|
39
|
+
# A final component's suffix split, following the running interpreter's
|
|
40
|
+
# pathlib: 3.14 treats a trailing "." as a suffix ("a." -> ".") and ignores
|
|
41
|
+
# leading dots; earlier versions do neither.
|
|
42
|
+
if _sys.version_info >= (3, 14):
|
|
43
|
+
|
|
44
|
+
def _name_suffix(name: str) -> str:
|
|
45
|
+
name = name.lstrip(".")
|
|
46
|
+
i = name.rfind(".")
|
|
47
|
+
return name[i:] if i != -1 else ""
|
|
48
|
+
|
|
49
|
+
def _name_stem(name: str) -> str:
|
|
50
|
+
i = name.rfind(".")
|
|
51
|
+
if i != -1:
|
|
52
|
+
stem = name[:i]
|
|
53
|
+
# The stem must contain at least one non-dot character.
|
|
54
|
+
if stem.lstrip("."):
|
|
55
|
+
return stem
|
|
56
|
+
return name
|
|
57
|
+
|
|
58
|
+
def _name_suffixes(name: str) -> "list[str]":
|
|
59
|
+
return ["." + ext for ext in name.lstrip(".").split(".")[1:]]
|
|
60
|
+
|
|
61
|
+
else:
|
|
62
|
+
|
|
63
|
+
def _name_suffix(name: str) -> str:
|
|
64
|
+
i = name.rfind(".")
|
|
65
|
+
return name[i:] if 0 < i < len(name) - 1 else ""
|
|
66
|
+
|
|
67
|
+
def _name_stem(name: str) -> str:
|
|
68
|
+
i = name.rfind(".")
|
|
69
|
+
return name[:i] if 0 < i < len(name) - 1 else name
|
|
70
|
+
|
|
71
|
+
def _name_suffixes(name: str) -> "list[str]":
|
|
72
|
+
if name.endswith("."):
|
|
73
|
+
return []
|
|
74
|
+
return ["." + suffix for suffix in name.lstrip(".").split(".")[1:]]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FsPathLike(_ty.Protocol):
|
|
78
|
+
"""Anything implementing `__fspath__`, for annotations. A class qualifies
|
|
79
|
+
by defining `__fspath__` itself, and `os.PathLike` recognises it the same
|
|
80
|
+
way; this protocol is not registered with `os.PathLike`, so a class that
|
|
81
|
+
does not define one is rejected by `os.fspath()` with `TypeError`."""
|
|
82
|
+
|
|
83
|
+
__slots__ = ()
|
|
84
|
+
|
|
85
|
+
def __fspath__(self) -> str:
|
|
86
|
+
return _stubs._unimplemented("__fspath__")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _PathnameParents(_ty.Sequence[PN]):
|
|
90
|
+
"""This object provides sequence-like access to the logical ancestors
|
|
91
|
+
of a path. Don't try to construct it yourself."""
|
|
92
|
+
|
|
93
|
+
__slots__ = ("_path", "_anchor", "_segments")
|
|
94
|
+
|
|
95
|
+
def __init__(self, path: PN):
|
|
96
|
+
self._path = path
|
|
97
|
+
segments = tuple(path.segments)
|
|
98
|
+
# The anchor (a leading "" for the root) is no ancestor of its own: counting
|
|
99
|
+
# it adds an entry and spells the root as the relative empty path.
|
|
100
|
+
self._anchor = path._anchor_segments()
|
|
101
|
+
segments = segments[len(self._anchor) :]
|
|
102
|
+
while segments and not segments[-1]:
|
|
103
|
+
segments = segments[:-1]
|
|
104
|
+
self._segments = segments
|
|
105
|
+
|
|
106
|
+
def __len__(self):
|
|
107
|
+
return len(self._segments)
|
|
108
|
+
|
|
109
|
+
@_ty.overload
|
|
110
|
+
def __getitem__(self, idx: slice) -> tuple[PN]: ...
|
|
111
|
+
@_ty.overload
|
|
112
|
+
def __getitem__(self, idx: int) -> PN: ...
|
|
113
|
+
def __getitem__(self, idx: int | slice) -> tuple[PN] | PN:
|
|
114
|
+
if isinstance(idx, slice):
|
|
115
|
+
return tuple(self[i] for i in range(*idx.indices(len(self))))
|
|
116
|
+
|
|
117
|
+
if idx >= len(self) or idx < -len(self):
|
|
118
|
+
raise IndexError(idx)
|
|
119
|
+
if idx < 0:
|
|
120
|
+
idx += len(self)
|
|
121
|
+
kept = self._segments[: -idx - 1]
|
|
122
|
+
if not self._anchor:
|
|
123
|
+
return self._path.with_segments(*kept)
|
|
124
|
+
# ("", "") is the root in with_segments' "/"-joined spelling.
|
|
125
|
+
return self._path.with_segments(*self._anchor, *(kept or ("",)))
|
|
126
|
+
|
|
127
|
+
def __repr__(self):
|
|
128
|
+
return "<{}.parents>".format(type(self._path).__name__)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Pathname(_ty.Generic[_P]):
|
|
132
|
+
"""Base class for manipulating paths without I/O."""
|
|
133
|
+
|
|
134
|
+
__slots__ = ()
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def _is_case_sensitive(self) -> bool:
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
@_abstract
|
|
141
|
+
def as_uri(self) -> str:
|
|
142
|
+
"""Return the path as a URI string."""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def name(self) -> str:
|
|
147
|
+
"""The final path component, if any."""
|
|
148
|
+
segments = self.segments
|
|
149
|
+
return "" if not segments else segments[-1]
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def suffix(self) -> str:
|
|
153
|
+
"""
|
|
154
|
+
The final component's last suffix, if any.
|
|
155
|
+
|
|
156
|
+
This includes the leading period. For example: '.txt'
|
|
157
|
+
"""
|
|
158
|
+
return _name_suffix(self.name)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def suffixes(self):
|
|
162
|
+
"""
|
|
163
|
+
A list of the final component's suffixes, if any.
|
|
164
|
+
|
|
165
|
+
These include the leading periods. For example: ['.tar', '.gz']
|
|
166
|
+
"""
|
|
167
|
+
return _name_suffixes(self.name)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def stem(self):
|
|
171
|
+
"""The final path component, minus its last suffix."""
|
|
172
|
+
return _name_stem(self.name)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
@_abstract
|
|
176
|
+
def segments(self) -> _ty.Sequence[str]:
|
|
177
|
+
"""The sequence of path component strings."""
|
|
178
|
+
...
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
@_abstract
|
|
182
|
+
def parts(self) -> _P:
|
|
183
|
+
"""The individual components/parts of the path."""
|
|
184
|
+
...
|
|
185
|
+
|
|
186
|
+
@_abstract
|
|
187
|
+
def with_segments(self, *segments: str) -> _ty.Self:
|
|
188
|
+
"""Construct a same-type path instance from new segments."""
|
|
189
|
+
...
|
|
190
|
+
|
|
191
|
+
def with_name(self, name: str) -> _ty.Self:
|
|
192
|
+
"""Return a new path with the name changed.
|
|
193
|
+
|
|
194
|
+
Raises ValueError for an empty name, ".", or a name containing "/",
|
|
195
|
+
as pathlib does. Without the check a name such as "../../etc/passwd"
|
|
196
|
+
or "x/y" was spliced in verbatim, so a caller relying on pathlib's
|
|
197
|
+
validation of an untrusted file name got a path outside the
|
|
198
|
+
directory. A bare ".." is accepted, also as pathlib does; use
|
|
199
|
+
`providers.is_safe_child_name()` to reject it. `with_stem()`/
|
|
200
|
+
`with_suffix()` validate their result through here.
|
|
201
|
+
"""
|
|
202
|
+
if not name or "/" in name or name == ".":
|
|
203
|
+
raise ValueError("Invalid name %r" % (name,))
|
|
204
|
+
if not self.name:
|
|
205
|
+
raise ValueError("%r has an empty name" % (self,))
|
|
206
|
+
return self.with_segments(*self.segments[:-1], name)
|
|
207
|
+
|
|
208
|
+
def with_stem(self, stem: str) -> _ty.Self:
|
|
209
|
+
"""Return a new path with the stem changed (validated like
|
|
210
|
+
`with_name()`). From 3.13 on an empty stem is refused when the name
|
|
211
|
+
has a suffix, as `pathlib` does: it would leave a name made of the
|
|
212
|
+
suffix alone."""
|
|
213
|
+
suffix = self.suffix
|
|
214
|
+
if _STEM_KEEPS_SUFFIX and suffix and not stem:
|
|
215
|
+
raise ValueError("%r has a non-empty suffix" % (self,))
|
|
216
|
+
return self.with_name(stem + suffix)
|
|
217
|
+
|
|
218
|
+
def with_suffix(self, suffix: str) -> _ty.Self:
|
|
219
|
+
"""Return a new path with the suffix changed or added."""
|
|
220
|
+
name = self.name
|
|
221
|
+
if (
|
|
222
|
+
suffix
|
|
223
|
+
and not suffix.startswith(".")
|
|
224
|
+
or (suffix == "." and _sys.version_info < (3, 14))
|
|
225
|
+
):
|
|
226
|
+
raise ValueError("Invalid suffix %r" % (suffix))
|
|
227
|
+
if not name:
|
|
228
|
+
raise ValueError("%r has an empty name" % (self,))
|
|
229
|
+
old_suffix = self.suffix
|
|
230
|
+
if not old_suffix:
|
|
231
|
+
name = name + suffix
|
|
232
|
+
else:
|
|
233
|
+
name = name[: -len(old_suffix)] + suffix
|
|
234
|
+
return self.with_name(name)
|
|
235
|
+
|
|
236
|
+
@_abstract
|
|
237
|
+
def relative_to(self, other: _ty.Self | str) -> _ty.Self:
|
|
238
|
+
"""Return the relative path to another path identified by the passed
|
|
239
|
+
arguments. If the operation is not possible (because this is not
|
|
240
|
+
related to the other path), raise ValueError.
|
|
241
|
+
"""
|
|
242
|
+
...
|
|
243
|
+
|
|
244
|
+
def is_relative_to(self, other: _ty.Self | str):
|
|
245
|
+
"""Return True if the path is relative to another path or False."""
|
|
246
|
+
cls = type(self)
|
|
247
|
+
# `other` is parsed standalone, as CPython does, not joined under `self`;
|
|
248
|
+
# `with_segments()` rather than the constructor keeps per-instance state
|
|
249
|
+
# such as MemPath's backend.
|
|
250
|
+
other = other if isinstance(other, cls) else self.with_segments(other)
|
|
251
|
+
return other == self or other in self.parents
|
|
252
|
+
|
|
253
|
+
def __eq__(self, other: object) -> bool:
|
|
254
|
+
"""Compare by (exact type, segments).
|
|
255
|
+
|
|
256
|
+
Identity equality would make `is_relative_to()` (which decides via
|
|
257
|
+
`==`) return False for every subclass and break paths as dict keys
|
|
258
|
+
or set members, so a pure subclass that defines none gets this one.
|
|
259
|
+
`_BaseFSPathname`/`LocalPath` are unaffected: `pathlib.PurePath`
|
|
260
|
+
precedes `Pathname` in their MRO and keeps its own `__eq__`, as
|
|
261
|
+
does any class that defines one.
|
|
262
|
+
"""
|
|
263
|
+
if type(self) is not type(other):
|
|
264
|
+
return NotImplemented
|
|
265
|
+
return tuple(self.segments) == tuple(other.segments)
|
|
266
|
+
|
|
267
|
+
def __hash__(self) -> int:
|
|
268
|
+
return hash((type(self), tuple(self.segments)))
|
|
269
|
+
|
|
270
|
+
def _order_key(self) -> _ty.Any:
|
|
271
|
+
"""What `<`, `<=`, `>` and `>=` compare, between two paths of one
|
|
272
|
+
exact type: the segments, as `__eq__` does. A type whose `__eq__`
|
|
273
|
+
compares something else overrides it with that."""
|
|
274
|
+
return tuple(self.segments)
|
|
275
|
+
|
|
276
|
+
def _compare(
|
|
277
|
+
self, other: Pathname, compare: _ty.Callable[[_ty.Any, _ty.Any], bool]
|
|
278
|
+
):
|
|
279
|
+
if type(self) is not type(other):
|
|
280
|
+
return NotImplemented
|
|
281
|
+
return compare(self._order_key(), other._order_key())
|
|
282
|
+
|
|
283
|
+
def __lt__(self, other: Pathname) -> bool:
|
|
284
|
+
return self._compare(other, _operator.lt)
|
|
285
|
+
|
|
286
|
+
def __le__(self, other: Pathname) -> bool:
|
|
287
|
+
return self._compare(other, _operator.le)
|
|
288
|
+
|
|
289
|
+
def __gt__(self, other: Pathname) -> bool:
|
|
290
|
+
return self._compare(other, _operator.gt)
|
|
291
|
+
|
|
292
|
+
def __ge__(self, other: Pathname) -> bool:
|
|
293
|
+
return self._compare(other, _operator.ge)
|
|
294
|
+
|
|
295
|
+
def __truediv__(self, key: _ty.Self | str) -> _ty.Self:
|
|
296
|
+
try:
|
|
297
|
+
return type(self)(self, key)
|
|
298
|
+
except (TypeError, NotImplementedError):
|
|
299
|
+
return NotImplemented
|
|
300
|
+
|
|
301
|
+
def __rtruediv__(self, key: str) -> _ty.Self:
|
|
302
|
+
"""`"prefix" / path`, as pathlib supports. `self` is passed as an
|
|
303
|
+
object, so per-instance state it carries (a `MemPath` backend)
|
|
304
|
+
survives, and an absolute `self` restarts the join."""
|
|
305
|
+
if not isinstance(key, str):
|
|
306
|
+
return NotImplemented
|
|
307
|
+
try:
|
|
308
|
+
return type(self)(key, self)
|
|
309
|
+
except (TypeError, NotImplementedError):
|
|
310
|
+
return NotImplemented
|
|
311
|
+
|
|
312
|
+
def joinpath(self, *args: str | _ty.Self) -> _ty.Self:
|
|
313
|
+
"""Combine this path with one or more paths/segments."""
|
|
314
|
+
return type(self)(self, *args)
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def root(self) -> str:
|
|
318
|
+
"""The root of the path, if any (e.g. "/"). See `docs/divergences.md`
|
|
319
|
+
for how this is derived generically vs. LocalPath's real one."""
|
|
320
|
+
segments = self.segments
|
|
321
|
+
return "/" if segments and not segments[0] else ""
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def drive(self) -> str:
|
|
325
|
+
"""No generic concept of a drive; always "". LocalPath gets a real
|
|
326
|
+
one from pathlib on Windows."""
|
|
327
|
+
return ""
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def anchor(self) -> str:
|
|
331
|
+
"""`drive + root`."""
|
|
332
|
+
return self.drive + self.root
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
@_abstract
|
|
336
|
+
def parent(self) -> _ty.Self:
|
|
337
|
+
"""The logical parent of the path."""
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def parents(self) -> _ty.Sequence[_ty.Self]:
|
|
341
|
+
"""An immutable sequence providing access to the logical ancestors of the path."""
|
|
342
|
+
return _PathnameParents(self)
|
|
343
|
+
|
|
344
|
+
def _anchor_segments(self) -> tuple[str, ...]:
|
|
345
|
+
"""The leading entries of `segments` that anchor the path rather than
|
|
346
|
+
name a directory, for `parents`: `("",)` for a rooted path, else
|
|
347
|
+
`()`. A subclass that spells its root differently overrides it."""
|
|
348
|
+
segments = self.segments
|
|
349
|
+
return ("",) if segments and segments[0] == "" else ()
|
|
350
|
+
|
|
351
|
+
def is_absolute(self) -> bool:
|
|
352
|
+
"""True if the path is absolute: it has a root. A class whose paths
|
|
353
|
+
can be rooted without being absolute overrides it."""
|
|
354
|
+
return bool(self.root)
|
|
355
|
+
|
|
356
|
+
def _match_parts(self) -> tuple[bool, list[str]]:
|
|
357
|
+
"""`(anchored, names)`: the path as `match()` sees it -- whether it
|
|
358
|
+
is rooted, and its names with empty and "." segments dropped, the
|
|
359
|
+
way `PurePosixPath` parses a string."""
|
|
360
|
+
segments = self.segments
|
|
361
|
+
anchored = bool(segments) and segments[0] == ""
|
|
362
|
+
return anchored, [s for s in segments if s and s != "."]
|
|
363
|
+
|
|
364
|
+
def match(self, path_pattern: str | _re.Pattern, *, case_sensitive=None):
|
|
365
|
+
"""
|
|
366
|
+
Return True if this path matches the given glob-style pattern.
|
|
367
|
+
|
|
368
|
+
pathlib's semantics on the running interpreter: a relative pattern
|
|
369
|
+
matches the last N segments (from the right), an absolute pattern
|
|
370
|
+
must match the whole path, and each segment is matched on its own,
|
|
371
|
+
so `*` never crosses a "/". "**" acts like "*" (use `full_match()`
|
|
372
|
+
for recursive matching). An empty pattern raises ValueError.
|
|
373
|
+
|
|
374
|
+
A compiled `re.Pattern` cannot be split into segments; it is matched
|
|
375
|
+
against the whole path string instead ("/"-joined segments, so a
|
|
376
|
+
URI's scheme and host are never part of it).
|
|
377
|
+
"""
|
|
378
|
+
import fnmatch as _fnmatch
|
|
379
|
+
|
|
380
|
+
if case_sensitive is None:
|
|
381
|
+
case_sensitive = self._is_case_sensitive
|
|
382
|
+
anchored, names = self._match_parts()
|
|
383
|
+
if isinstance(path_pattern, _re.Pattern):
|
|
384
|
+
path = ("/" if anchored else "") + "/".join(names)
|
|
385
|
+
return path_pattern.match(path) is not None
|
|
386
|
+
if isinstance(path_pattern, Pathname):
|
|
387
|
+
path_pattern = "/".join(path_pattern.segments)
|
|
388
|
+
elif not isinstance(path_pattern, str):
|
|
389
|
+
path_pattern = _os.fspath(path_pattern)
|
|
390
|
+
pattern_anchored = path_pattern.startswith("/")
|
|
391
|
+
pattern_names = [s for s in path_pattern.split("/") if s and s != "."]
|
|
392
|
+
if not pattern_names and not pattern_anchored:
|
|
393
|
+
raise ValueError("empty pattern")
|
|
394
|
+
if _sys.version_info[:2] == (3, 12):
|
|
395
|
+
# 3.12 matches the whole path as one string with its separators swapped for
|
|
396
|
+
# newlines, which differs at the edges: see `_match_lines_312`.
|
|
397
|
+
return _match_lines_312(
|
|
398
|
+
anchored, names, pattern_anchored, pattern_names, case_sensitive
|
|
399
|
+
)
|
|
400
|
+
flags = 0 if case_sensitive else _re.IGNORECASE
|
|
401
|
+
if pattern_anchored and not (anchored and len(names) == len(pattern_names)):
|
|
402
|
+
return False
|
|
403
|
+
# The root counts as one more part a relative pattern can reach.
|
|
404
|
+
path_parts = len(names) + anchored
|
|
405
|
+
if len(pattern_names) > path_parts:
|
|
406
|
+
return False
|
|
407
|
+
for index, pattern in enumerate(reversed(pattern_names)):
|
|
408
|
+
if index == len(names):
|
|
409
|
+
# A relative pattern as long as the path reaches its root. 3.13+ compiles the
|
|
410
|
+
# root like any part (a bracket expression matches "/", "*" and "?" do not);
|
|
411
|
+
# 3.9-3.11 fnmatch the root string, so "*" and "?" match it too.
|
|
412
|
+
if _sys.version_info >= (3, 13):
|
|
413
|
+
from ..fspath import _translate_segment
|
|
414
|
+
|
|
415
|
+
regex = f"(?s:{_translate_segment(pattern, '[^/]')})\\Z"
|
|
416
|
+
return _re.match(regex, "/", flags) is not None
|
|
417
|
+
return _re.match(_fnmatch.translate(pattern), "/", flags) is not None
|
|
418
|
+
name = names[len(names) - 1 - index]
|
|
419
|
+
if _re.match(_fnmatch.translate(pattern), name, flags) is None:
|
|
420
|
+
return False
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
def full_match(
|
|
424
|
+
self, pattern: str | _os.PathLike, *, case_sensitive: bool | None = None
|
|
425
|
+
) -> bool:
|
|
426
|
+
"""Return True if this path matches the glob-style `pattern`
|
|
427
|
+
against the whole path (3.13 parity). Unlike match(), this isn't a
|
|
428
|
+
right-anchored partial match, and "**" matches any number of path
|
|
429
|
+
segments (including zero). The root is a component of its own: a
|
|
430
|
+
rooted path needs a rooted pattern, or a leading "**" that reaches
|
|
431
|
+
past it (see `glob.full_match`). Empty and "." components of
|
|
432
|
+
the pattern are ignored, as `pathlib` parses its pattern as a path.
|
|
433
|
+
"""
|
|
434
|
+
if case_sensitive is None:
|
|
435
|
+
case_sensitive = self._is_case_sensitive
|
|
436
|
+
if isinstance(pattern, Pathname):
|
|
437
|
+
pattern = "/".join(pattern.segments)
|
|
438
|
+
elif not isinstance(pattern, str):
|
|
439
|
+
pattern = _os.fspath(pattern)
|
|
440
|
+
return _glob.full_match(self.segments, pattern, case_sensitive)
|
|
441
|
+
|
|
442
|
+
def as_posix(self) -> str:
|
|
443
|
+
"""Return the string representation of the path with forward slashes."""
|
|
444
|
+
return "/".join(self.segments)
|
|
445
|
+
|
|
446
|
+
def has_glob_pattern(self):
|
|
447
|
+
"""Return True if any of the path segments contain glob wildcards.
|
|
448
|
+
|
|
449
|
+
The anchor is not one of them: a Windows extended-length path
|
|
450
|
+
(`\\\\?\\C:\\data`, and the `\\\\?\\UNC\\server\\share` form) carries a
|
|
451
|
+
literal `?` in its drive, which is a prefix, not a wildcard -- so a
|
|
452
|
+
caller asking "is this a pattern or a plain path?" got `True` for
|
|
453
|
+
every such path.
|
|
454
|
+
"""
|
|
455
|
+
segments = list(self.segments)
|
|
456
|
+
anchor = getattr(self, "anchor", "")
|
|
457
|
+
if anchor and segments and segments[0] == anchor:
|
|
458
|
+
segments = segments[1:]
|
|
459
|
+
for segment in segments:
|
|
460
|
+
if _glob.WILDCARD_PATTERN.search(segment) is not None:
|
|
461
|
+
return True
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
PurePathLike = _ty.Union[str, Pathname]
|
|
466
|
+
|
|
467
|
+
#: `fnmatch.translate()` wraps its output in "(?s:...)\\Z"; 3.12 strips that
|
|
468
|
+
#: to splice parts together, which is what drops the DOTALL flag and lets a
|
|
469
|
+
#: bracket expression -- but not "." -- match the newline separator.
|
|
470
|
+
_FNMATCH_SLICE = slice(len("(?s:"), -len(")\\Z"))
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
#: 3.12 swaps in both directions, so a newline inside a name becomes a "/" and
|
|
474
|
+
#: can never act as a separator.
|
|
475
|
+
_SWAP_SEP_AND_NEWLINE = str.maketrans({"/": "\n", "\n": "/"})
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _match_lines_312(
|
|
479
|
+
anchored: bool,
|
|
480
|
+
names: "_ty.Sequence[str]",
|
|
481
|
+
pattern_anchored: bool,
|
|
482
|
+
pattern_names: "_ty.Sequence[str]",
|
|
483
|
+
case_sensitive: bool,
|
|
484
|
+
) -> bool:
|
|
485
|
+
"""`match()` as Python 3.12 implements it, for 3.12 only.
|
|
486
|
+
|
|
487
|
+
3.12 swaps separators for newlines and matches the resulting strings
|
|
488
|
+
with one regex (`_compile_pattern_lines` in its `pathlib`), instead of
|
|
489
|
+
comparing parts pairwise the way every other version does. The
|
|
490
|
+
difference shows at the edges: the root is "\n", a single line that a
|
|
491
|
+
part matching the empty string ("**", but not "*", which 3.12 compiles
|
|
492
|
+
as ".+") reaches from either side, and a bracket expression such as
|
|
493
|
+
"[!a]" can consume the newline itself. Porting the algorithm keeps those
|
|
494
|
+
edges right without enumerating them.
|
|
495
|
+
"""
|
|
496
|
+
import fnmatch as _fnmatch
|
|
497
|
+
|
|
498
|
+
# pathlib spells an empty path "." and gives it no lines at all.
|
|
499
|
+
path_lines = (("/" if anchored else "") + "/".join(names)).translate(
|
|
500
|
+
_SWAP_SEP_AND_NEWLINE
|
|
501
|
+
)
|
|
502
|
+
pattern_lines = (
|
|
503
|
+
("/" if pattern_anchored else "") + "/".join(pattern_names)
|
|
504
|
+
).translate(_SWAP_SEP_AND_NEWLINE)
|
|
505
|
+
parts = ["^"]
|
|
506
|
+
for part in pattern_lines.splitlines(keepends=True):
|
|
507
|
+
if part == "*\n":
|
|
508
|
+
part = r".+\n"
|
|
509
|
+
elif part == "*":
|
|
510
|
+
part = r".+"
|
|
511
|
+
else:
|
|
512
|
+
part = _fnmatch.translate(part)[_FNMATCH_SLICE]
|
|
513
|
+
parts.append(part)
|
|
514
|
+
parts.append(r"\Z")
|
|
515
|
+
flags = _re.MULTILINE
|
|
516
|
+
if not case_sensitive:
|
|
517
|
+
flags |= _re.IGNORECASE
|
|
518
|
+
regex = _re.compile("".join(parts), flags=flags)
|
|
519
|
+
# An anchored pattern must match the whole path, a relative one its tail.
|
|
520
|
+
if pattern_anchored:
|
|
521
|
+
return regex.match(path_lines) is not None
|
|
522
|
+
return regex.search(path_lines) is not None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .fs import Chmod, Chown, Create, FileStatLike, Owned, Stat, Utime
|
|
4
|
+
from .io import BinaryOpen
|
|
5
|
+
from .checksum import NativeChecksum
|
|
6
|
+
from .pure import Named
|
|
7
|
+
from .tree import Listing, Remove
|
|
8
|
+
from .links import Hardlink, Readlink, Symlink
|
|
9
|
+
from .resolve import Resolve
|
|
10
|
+
from .transfer import Copy, Identity, Move
|
|
11
|
+
from ._support import capabilities, supports
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BinaryOpen",
|
|
15
|
+
"Chmod",
|
|
16
|
+
"Chown",
|
|
17
|
+
"Copy",
|
|
18
|
+
"Create",
|
|
19
|
+
"FileStatLike",
|
|
20
|
+
"Hardlink",
|
|
21
|
+
"Identity",
|
|
22
|
+
"Listing",
|
|
23
|
+
"Move",
|
|
24
|
+
"Named",
|
|
25
|
+
"NativeChecksum",
|
|
26
|
+
"Owned",
|
|
27
|
+
"Readlink",
|
|
28
|
+
"Remove",
|
|
29
|
+
"Resolve",
|
|
30
|
+
"Stat",
|
|
31
|
+
"Symlink",
|
|
32
|
+
"Utime",
|
|
33
|
+
"capabilities",
|
|
34
|
+
"supports",
|
|
35
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""What a helper reads from a path, as one type: a pure path plus the
|
|
2
|
+
protocols the helper calls. Used in annotations."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import typing as _ty
|
|
7
|
+
|
|
8
|
+
from .io import BinaryOpen
|
|
9
|
+
from .pure import Named
|
|
10
|
+
from .tree import Listing
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Walkable(Named, Listing, _ty.Protocol):
|
|
14
|
+
"""A path that can be listed and stat'ed: what `glob` and `walk` read."""
|
|
15
|
+
|
|
16
|
+
__slots__ = ()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Readable(Walkable, BinaryOpen, _ty.Protocol):
|
|
20
|
+
"""A path whose files can be listed and read: a `PathSyncer` source."""
|
|
21
|
+
|
|
22
|
+
__slots__ = ()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The error shape the operations of the protocols raise."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os as _os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _os_error(exc_type: type, code: int, path: object) -> OSError:
|
|
9
|
+
"""`exc_type(code, strerror, str(path))` -- an OSError shaped like the
|
|
10
|
+
ones `os` raises. `FileNotFoundError(path)` left `errno` and `filename`
|
|
11
|
+
as None, so `e.errno == errno.ENOENT` never matched."""
|
|
12
|
+
return exc_type(code, _os.strerror(code), str(path))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""`Path.info`: the file type of one path from at most two stats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import stat as _stat
|
|
6
|
+
import typing as _ty
|
|
7
|
+
|
|
8
|
+
if _ty.TYPE_CHECKING:
|
|
9
|
+
from ..path import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _PathInfo:
|
|
13
|
+
"""`Path.info`: the file type of one path, from at most one stat that
|
|
14
|
+
follows links and one that does not, both kept on this object only."""
|
|
15
|
+
|
|
16
|
+
__slots__ = ("_path", "_followed", "_unfollowed")
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: Path) -> None:
|
|
19
|
+
self._path = path
|
|
20
|
+
self._followed = self._unfollowed = _UNSET
|
|
21
|
+
|
|
22
|
+
def _stat(self, follow_symlinks: bool):
|
|
23
|
+
"""The stat, or None where there is no such entry."""
|
|
24
|
+
if follow_symlinks:
|
|
25
|
+
if self._followed is _UNSET:
|
|
26
|
+
# An entry that is missing or is not a link is the same stat
|
|
27
|
+
# either way.
|
|
28
|
+
plain = self._unfollowed
|
|
29
|
+
if plain is _UNSET or (
|
|
30
|
+
plain is not None and _stat.S_ISLNK(plain.st_mode)
|
|
31
|
+
):
|
|
32
|
+
self._followed = self._ask(True)
|
|
33
|
+
else:
|
|
34
|
+
self._followed = plain
|
|
35
|
+
return self._followed
|
|
36
|
+
if self._unfollowed is _UNSET:
|
|
37
|
+
self._unfollowed = self._ask(False)
|
|
38
|
+
return self._unfollowed
|
|
39
|
+
|
|
40
|
+
def _ask(self, follow_symlinks: bool):
|
|
41
|
+
try:
|
|
42
|
+
return self._path.stat(follow_symlinks=follow_symlinks)
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def exists(self, *, follow_symlinks: bool = True) -> bool:
|
|
47
|
+
return self._stat(follow_symlinks) is not None
|
|
48
|
+
|
|
49
|
+
def is_dir(self, *, follow_symlinks: bool = True) -> bool:
|
|
50
|
+
stat = self._stat(follow_symlinks)
|
|
51
|
+
return stat is not None and _stat.S_ISDIR(stat.st_mode)
|
|
52
|
+
|
|
53
|
+
def is_file(self, *, follow_symlinks: bool = True) -> bool:
|
|
54
|
+
stat = self._stat(follow_symlinks)
|
|
55
|
+
return stat is not None and _stat.S_ISREG(stat.st_mode)
|
|
56
|
+
|
|
57
|
+
def is_symlink(self) -> bool:
|
|
58
|
+
stat = self._stat(False)
|
|
59
|
+
return stat is not None and _stat.S_ISLNK(stat.st_mode)
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
return f"<{type(self._path).__name__}.info>"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_UNSET = object()
|