pathapi-uri 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_uri-0.1.0.dist-info/METADATA +259 -0
- pathapi_uri-0.1.0.dist-info/RECORD +48 -0
- pathapi_uri-0.1.0.dist-info/WHEEL +4 -0
- pathapi_uri-0.1.0.dist-info/entry_points.txt +17 -0
- pathapi_uri-0.1.0.dist-info/licenses/LICENSE +21 -0
- uripath/AGENTS.md +1094 -0
- uripath/README.md +213 -0
- uripath/__init__.py +27 -0
- uripath/__main__.py +7 -0
- uripath/_pathtext.py +32 -0
- uripath/_rfc3986.py +67 -0
- uripath/_transfer.py +108 -0
- uripath/_uri.py +1007 -0
- uripath/_version.py +11 -0
- uripath/cli/__init__.py +54 -0
- uripath/cli/_common.py +48 -0
- uripath/cli/_root.py +70 -0
- uripath/cli/cp.py +61 -0
- uripath/cli/read.py +22 -0
- uripath/cli/rm.py +38 -0
- uripath/cli/sync.py +78 -0
- uripath/cli/write.py +49 -0
- uripath/exceptions.py +38 -0
- uripath/py.typed +1 -0
- uripath/query.py +240 -0
- uripath/schemes/__init__.py +120 -0
- uripath/schemes/_archive.py +338 -0
- uripath/schemes/_data.py +99 -0
- uripath/schemes/_dav.py +459 -0
- uripath/schemes/_delegate.py +127 -0
- uripath/schemes/_elsewhere.py +102 -0
- uripath/schemes/_file.py +186 -0
- uripath/schemes/_ftp.py +200 -0
- uripath/schemes/_http/__init__.py +4 -0
- uripath/schemes/_http/_backend.py +132 -0
- uripath/schemes/_http/_headers.py +8 -0
- uripath/schemes/_http/_listing.py +403 -0
- uripath/schemes/_http/_path.py +371 -0
- uripath/schemes/_http/_settings.py +6 -0
- uripath/schemes/_http/_streams.py +230 -0
- uripath/schemes/_httpdate.py +48 -0
- uripath/schemes/_path.py +629 -0
- uripath/schemes/_registry.py +134 -0
- uripath/source/__init__.py +20 -0
- uripath/source/_compose.py +212 -0
- uripath/source/_locality.py +44 -0
- uripath/source/_parse.py +183 -0
- uripath/source/_source.py +239 -0
uripath/_uri.py
ADDED
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib as _pathlib
|
|
5
|
+
import posixpath as _posix
|
|
6
|
+
import typing as _ty
|
|
7
|
+
|
|
8
|
+
if _ty.TYPE_CHECKING:
|
|
9
|
+
from typing import TypeAlias
|
|
10
|
+
else:
|
|
11
|
+
TypeAlias = _ty.Any
|
|
12
|
+
|
|
13
|
+
from pathapi import Path, Pathname
|
|
14
|
+
from pathapi.providers import name_stem as _name_stem
|
|
15
|
+
from pathapi.providers import name_suffix as _name_suffix
|
|
16
|
+
from . import _rfc3986
|
|
17
|
+
from .query import Query, QueryLike, _coerce
|
|
18
|
+
from .source import Source
|
|
19
|
+
from .source._compose import _encode
|
|
20
|
+
from .source._parse import _ERRORS, _is_drive, _remove_dot_segments, fold_host
|
|
21
|
+
from .source._source import _parse_source, _same_authority, _source_parts
|
|
22
|
+
from .source._compose import _compose_uri
|
|
23
|
+
|
|
24
|
+
URILike: TypeAlias = "str | URI | os.PathLike"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_NOSOURCE = Source(None, None, None, None)
|
|
28
|
+
_NO_QUERY = Query.parse("")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _segments_of(path: str) -> list[str]:
|
|
32
|
+
"""Split a normalized posix-style path into segments for prefix
|
|
33
|
+
comparison. "/" alone must yield [""] (the root), not ["", ""]
|
|
34
|
+
(str.split's artifact for a string ending in "/")."""
|
|
35
|
+
if path == "/":
|
|
36
|
+
return [""]
|
|
37
|
+
return path.split("/")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _uriencode(text: str, safe=""):
|
|
41
|
+
return _encode(text, safe)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _path_reference(posix: str) -> str:
|
|
45
|
+
"""Encode a posix path as a URI reference that parses back to the same
|
|
46
|
+
path. A path starting with "//" (POSIX keeps exactly two leading
|
|
47
|
+
slashes) would otherwise parse as a network-path reference whose first
|
|
48
|
+
segment is a host; "/." in front of it is removed again as a dot
|
|
49
|
+
segment (RFC 3986 5.2.4)."""
|
|
50
|
+
encoded = _uriencode(posix, safe="/")
|
|
51
|
+
return "/." + encoded if encoded.startswith("//") else encoded
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _RelativeLocalPath(str):
|
|
55
|
+
"""The encoded posix spelling of a *relative* concrete local path
|
|
56
|
+
(`pathlib.Path("a/b")`, `LocalPath("a/b")`) in `URI._raw_uris`.
|
|
57
|
+
|
|
58
|
+
It joins like a relative `PurePath` -- onto the preceding segments,
|
|
59
|
+
keeping their source -- and supplies a `file:` scheme only when no
|
|
60
|
+
segment has a source at all. Turning it into `file:a/b` up front let
|
|
61
|
+
that scheme replace the base's, so
|
|
62
|
+
`URIPath("sftp://h/srv/") / pathlib.Path("etc/x")` became the LOCAL
|
|
63
|
+
file `/srv/etc/x`."""
|
|
64
|
+
|
|
65
|
+
__slots__ = ()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_FILE_SOURCE = Source("file", None, None, None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _remove_dots(path: str, scheme: "str | None") -> str:
|
|
72
|
+
"""`_remove_dot_segments()`, keeping the drive of a `file:` path on
|
|
73
|
+
Windows."""
|
|
74
|
+
return _remove_dot_segments(path, drive=scheme == "file" and os.name == "nt")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _segment_text(item) -> str:
|
|
78
|
+
"""The text of one `with_segments()` element."""
|
|
79
|
+
if isinstance(item, str):
|
|
80
|
+
return item
|
|
81
|
+
if isinstance(item, bytes):
|
|
82
|
+
return item.decode()
|
|
83
|
+
if isinstance(item, (_pathlib.PurePath, Pathname)):
|
|
84
|
+
return item.as_posix()
|
|
85
|
+
path = os.fspath(item)
|
|
86
|
+
if isinstance(path, bytes):
|
|
87
|
+
path = path.decode()
|
|
88
|
+
return _pathlib.PurePath(path).as_posix()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _child_path(path: str, name: str, has_authority: bool) -> str:
|
|
92
|
+
"""The path of the child `name` of a directory at `path`: exactly one "/"
|
|
93
|
+
joins them (a directory's own path conventionally carries a trailing "/"
|
|
94
|
+
for some schemes, and "/" + "sub" must not become "//sub"). An empty path
|
|
95
|
+
with an authority is the same root as "/" (RFC 3986: "http://host" and
|
|
96
|
+
"http://host/" are equivalent); without one ("file:", "data:") it is
|
|
97
|
+
relative and stays so."""
|
|
98
|
+
if not path:
|
|
99
|
+
return f"/{name}" if has_authority else name
|
|
100
|
+
return (path if path.endswith("/") else f"{path}/") + name
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _empty_slashes(path: str, count: int, between: bool) -> int:
|
|
104
|
+
"""How many "/" spell `count` empty segments after `path`. With `between`
|
|
105
|
+
a name follows, and `_child_path()` supplies the slash that precedes it
|
|
106
|
+
only when `path` does not end in one."""
|
|
107
|
+
return count - (1 if path.endswith("/") else 0) + (1 if between else 0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _slot_names(cls: type) -> "tuple[str, ...]":
|
|
111
|
+
"""Every slot a `cls` instance has, base classes included, each once."""
|
|
112
|
+
names: "dict[str, None]" = {}
|
|
113
|
+
for klass in cls.__mro__:
|
|
114
|
+
slots = klass.__dict__.get("__slots__", ())
|
|
115
|
+
for slot in (slots,) if isinstance(slots, str) else slots:
|
|
116
|
+
if slot not in ("__dict__", "__weakref__"):
|
|
117
|
+
names[slot] = None
|
|
118
|
+
return tuple(names)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _as_uri(value) -> "URI":
|
|
122
|
+
"""`value` as the constructor reads one argument: a `TypeError` for
|
|
123
|
+
what it cannot read."""
|
|
124
|
+
converted = URI.__new__(URI)
|
|
125
|
+
converted.__init__(value)
|
|
126
|
+
return converted
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _authority_of(source: Source) -> bool:
|
|
130
|
+
"""Whether `source` has an authority: a host (an empty one too, as in
|
|
131
|
+
`file:///x`), a userinfo or a port."""
|
|
132
|
+
return (
|
|
133
|
+
source.host is not None
|
|
134
|
+
or bool(source.userinfo)
|
|
135
|
+
or source.port not in (None, "")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _has_dot_segment(path: str) -> bool:
|
|
140
|
+
# A "." or ".." segment starts with a dot right after a "/" or at the
|
|
141
|
+
# start; a cheap superset test that decides whether to run the real one.
|
|
142
|
+
return "/." in path or path.startswith(".")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class URI(Pathname):
|
|
146
|
+
"""A pure (no I/O) RFC 3986 URI, lazily parsed into `source` (scheme/
|
|
147
|
+
userinfo/host/port), `path`, `query`, and `fragment` on first access.
|
|
148
|
+
Join semantics (multiple constructor args, or `/`) are pathlib-
|
|
149
|
+
`joinpath`-like, not RFC 3986 reference resolution -- see
|
|
150
|
+
`_load_parts`'s docstring and `docs/divergences.md`."""
|
|
151
|
+
|
|
152
|
+
__slots__ = (
|
|
153
|
+
"_raw_uris",
|
|
154
|
+
"_source",
|
|
155
|
+
"_path",
|
|
156
|
+
"_query",
|
|
157
|
+
"_fragment",
|
|
158
|
+
"_uri",
|
|
159
|
+
"_initiated",
|
|
160
|
+
"_normalized_path",
|
|
161
|
+
"_segments_cache",
|
|
162
|
+
"_suffix_cache",
|
|
163
|
+
"_stem_cache",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
#: `True` on a subclass whose `.path` is a filesystem path on the URI's own host
|
|
167
|
+
#: (`sftp:`): `__fspath__`/`host_fspath()` return it for a command line that
|
|
168
|
+
#: runs on that host. `False`: `.path` has no such meaning (`http:`, `s3:`).
|
|
169
|
+
_host_filesystem_path = False
|
|
170
|
+
|
|
171
|
+
#: Every slot of the class, set by `__init_subclass__`; `__new__` starts
|
|
172
|
+
#: each one at None so `__init__` can tell an initialized instance.
|
|
173
|
+
_slot_names: "tuple[str, ...]" = ()
|
|
174
|
+
|
|
175
|
+
#: The keyword arguments the constructor takes. Any other is a `TypeError`;
|
|
176
|
+
#: `None` takes any (a path whose scheme another distribution implements).
|
|
177
|
+
_OPTIONS: "frozenset[str] | None" = frozenset()
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def _check_options(cls, options: "dict[str, object]") -> None:
|
|
181
|
+
accepted = cls._OPTIONS
|
|
182
|
+
if accepted is None:
|
|
183
|
+
return
|
|
184
|
+
for name in options:
|
|
185
|
+
if name not in accepted:
|
|
186
|
+
raise TypeError(
|
|
187
|
+
f"{cls.__name__}() got an unexpected keyword argument {name!r}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def __init_subclass__(cls, **kwargs):
|
|
191
|
+
super().__init_subclass__(**kwargs)
|
|
192
|
+
cls._slot_names = _slot_names(cls)
|
|
193
|
+
|
|
194
|
+
def __new__(cls, *uris, **options):
|
|
195
|
+
if options:
|
|
196
|
+
cls._check_options(options)
|
|
197
|
+
inst = object.__new__(cls)
|
|
198
|
+
for slot in cls._slot_names:
|
|
199
|
+
setattr(inst, slot, None)
|
|
200
|
+
return inst
|
|
201
|
+
|
|
202
|
+
def __init__(self, *uris: URILike, **options):
|
|
203
|
+
if options:
|
|
204
|
+
self._check_options(options)
|
|
205
|
+
if self._raw_uris or self._initiated:
|
|
206
|
+
return
|
|
207
|
+
_uris: list[str | URI] = []
|
|
208
|
+
for uri in uris:
|
|
209
|
+
if uri is None or (isinstance(uri, (str, bytes)) and not uri):
|
|
210
|
+
uri = ""
|
|
211
|
+
if isinstance(uri, URI):
|
|
212
|
+
_uris.append(uri)
|
|
213
|
+
elif isinstance(uri, (_pathlib.Path, Path)):
|
|
214
|
+
try:
|
|
215
|
+
uri = uri.as_uri()
|
|
216
|
+
except ValueError:
|
|
217
|
+
# as_uri() raises ValueError for a relative path, which
|
|
218
|
+
# joins like a relative PurePath (see _RelativeLocalPath).
|
|
219
|
+
uri = _RelativeLocalPath(_uriencode(uri.as_posix(), safe="/"))
|
|
220
|
+
_uris.append(uri)
|
|
221
|
+
elif isinstance(uri, (_pathlib.PurePath, Pathname)):
|
|
222
|
+
_uris.append(_path_reference(uri.as_posix()))
|
|
223
|
+
elif hasattr(uri, "as_uri"):
|
|
224
|
+
path = uri.as_uri
|
|
225
|
+
if callable(path):
|
|
226
|
+
path = path()
|
|
227
|
+
_uris.append(path)
|
|
228
|
+
elif isinstance(uri, str):
|
|
229
|
+
_uris.append(uri)
|
|
230
|
+
elif isinstance(uri, bytes):
|
|
231
|
+
_uris.append(uri.decode())
|
|
232
|
+
else:
|
|
233
|
+
path = None
|
|
234
|
+
try:
|
|
235
|
+
path = os.fspath(uri)
|
|
236
|
+
except (TypeError, NotImplementedError):
|
|
237
|
+
pass
|
|
238
|
+
if not isinstance(path, str):
|
|
239
|
+
raise TypeError(
|
|
240
|
+
"argument should be a str or an os.PathLike "
|
|
241
|
+
"object where __fspath__ returns a str, "
|
|
242
|
+
f"not {type(uri).__name__!r}"
|
|
243
|
+
)
|
|
244
|
+
# Only __fspath__ is guaranteed here -- posix-normalize the
|
|
245
|
+
# string itself rather than assuming an as_posix() method.
|
|
246
|
+
posix = _pathlib.PurePath(path).as_posix()
|
|
247
|
+
_uris.append(_path_reference(posix))
|
|
248
|
+
self._raw_uris = _uris
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def _parse_uri(cls, uri: str) -> tuple[Source, str, Query, str]:
|
|
252
|
+
"""`(source, path, query, fragment)` of `uri`, in one pass over its raw
|
|
253
|
+
components with the authority split once. The query is not decoded
|
|
254
|
+
(`.query` stays percent-encoded and is decoded per key and value by
|
|
255
|
+
`Query.to_pairs()`, because decoding it makes an escaped `&`, `=` or `+`
|
|
256
|
+
look like a delimiter); escapes that are not UTF-8 decode with
|
|
257
|
+
`surrogateescape` (`source._ERRORS`) instead of raising; and a `data:`
|
|
258
|
+
payload (RFC 2397) is an opaque octet string that gets no dot-segment
|
|
259
|
+
removal (`data:,a/./b` is the bytes `a/./b`)."""
|
|
260
|
+
scheme, authority, path, query, fragment = _rfc3986.split(uri)
|
|
261
|
+
scheme = scheme.lower() if scheme is not None else None
|
|
262
|
+
source = _parse_source(scheme, authority)
|
|
263
|
+
if scheme != "data":
|
|
264
|
+
path = _remove_dots(path, scheme)
|
|
265
|
+
decoded = _rfc3986.decode(path, _ERRORS)
|
|
266
|
+
if decoded != path and scheme != "data":
|
|
267
|
+
# An escaped dot (`%2e%2e`) or separator (`..%2Fx`) is a dot
|
|
268
|
+
# segment only now; `as_uri()` would otherwise render one that
|
|
269
|
+
# parses to a different path.
|
|
270
|
+
decoded = _remove_dots(decoded, scheme)
|
|
271
|
+
path = decoded
|
|
272
|
+
if fragment is not None:
|
|
273
|
+
fragment = _rfc3986.decode(fragment, _ERRORS)
|
|
274
|
+
return source, path, Query.parse(query or ""), fragment or ""
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def parts(self):
|
|
278
|
+
"""The tuple of URI components: (source, path, query, fragment)."""
|
|
279
|
+
return (self.source, self.path, self.query, self.fragment)
|
|
280
|
+
|
|
281
|
+
def _load_parts(self):
|
|
282
|
+
"""Join semantics (documented in docs/divergences.md -- deliberate, not
|
|
283
|
+
RFC 3986 reference resolution): multiple constructor arguments are
|
|
284
|
+
joined pathlib-`joinpath`-style, right to left, stopping at the
|
|
285
|
+
first absolute segment, and then RFC 3986 5.2.4 removes the dot
|
|
286
|
+
segments of the joined path (`URI("http://h/d/", "../x")` is
|
|
287
|
+
`http://h/x`). `source` is taken from the last (rightmost) segment
|
|
288
|
+
that has one, unless it names the endpoint an earlier segment gave
|
|
289
|
+
(that spelling is kept); `query`/`fragment` likewise come from the last
|
|
290
|
+
segment that actually sets one (a later segment with no
|
|
291
|
+
query/fragment does not blank out an earlier one).
|
|
292
|
+
"""
|
|
293
|
+
uris = self._raw_uris
|
|
294
|
+
source = _NOSOURCE
|
|
295
|
+
query = fragment = None
|
|
296
|
+
_path = ""
|
|
297
|
+
local = False
|
|
298
|
+
|
|
299
|
+
if not uris:
|
|
300
|
+
pass
|
|
301
|
+
elif len(uris) == 1 and isinstance(uris[0], URI):
|
|
302
|
+
source, _path, query, fragment = uris[0].parts
|
|
303
|
+
else:
|
|
304
|
+
paths: list[tuple[str, bool]] = []
|
|
305
|
+
for _uri in uris:
|
|
306
|
+
src, path, q, frag = (
|
|
307
|
+
_uri.parts if isinstance(_uri, URI) else self._parse_uri(_uri)
|
|
308
|
+
)
|
|
309
|
+
local = local or isinstance(_uri, _RelativeLocalPath)
|
|
310
|
+
if bool(src) and not (source and _same_authority(src, source)):
|
|
311
|
+
# A later segment that names the same endpoint keeps the
|
|
312
|
+
# spelling of the first (`file:///d` / `file:rel`).
|
|
313
|
+
source = src
|
|
314
|
+
if q:
|
|
315
|
+
query = q
|
|
316
|
+
if frag:
|
|
317
|
+
fragment = frag
|
|
318
|
+
# A `URI` argument says what restarts a join for its own
|
|
319
|
+
# scheme (a `FileURIPath` stores "D:/f" without the "/").
|
|
320
|
+
absolute = (
|
|
321
|
+
_uri._is_absolute_decoded(path)
|
|
322
|
+
if isinstance(_uri, URI)
|
|
323
|
+
else path.startswith("/")
|
|
324
|
+
)
|
|
325
|
+
paths.append((path, absolute))
|
|
326
|
+
|
|
327
|
+
for path, absolute in reversed(paths):
|
|
328
|
+
if not path:
|
|
329
|
+
continue
|
|
330
|
+
if path.endswith("/"):
|
|
331
|
+
_path = f"{path}{_path}"
|
|
332
|
+
elif _path:
|
|
333
|
+
_path = f"{path}/{_path}"
|
|
334
|
+
else:
|
|
335
|
+
_path = path
|
|
336
|
+
if absolute or _path.startswith("/"):
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
if local and not source:
|
|
340
|
+
# Nothing but relative local paths and sourceless strings:
|
|
341
|
+
# still a local file.
|
|
342
|
+
source = _FILE_SOURCE
|
|
343
|
+
if source.scheme == "data" and sum(1 for path, _ in paths if path) > 1:
|
|
344
|
+
raise NotImplementedError(
|
|
345
|
+
"a join with a data: URI: its path is the payload of a "
|
|
346
|
+
"single file, with no names, parents or children"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if _authority_of(source) and _path and not _path.startswith("/"):
|
|
350
|
+
_path = "/" + _path
|
|
351
|
+
|
|
352
|
+
if len(uris) > 1 and _has_dot_segment(_path) and source.scheme != "data":
|
|
353
|
+
# Each argument was resolved on its own; a `..` in one can now
|
|
354
|
+
# climb out of what precedes it.
|
|
355
|
+
_path = _remove_dots(_path, source.scheme)
|
|
356
|
+
|
|
357
|
+
self._init(source, _path, query, fragment)
|
|
358
|
+
|
|
359
|
+
def _init(
|
|
360
|
+
self,
|
|
361
|
+
source: Source,
|
|
362
|
+
path: str,
|
|
363
|
+
query: Query | str | None,
|
|
364
|
+
fragment: str,
|
|
365
|
+
**kwargs,
|
|
366
|
+
):
|
|
367
|
+
# Re-init of an initiated instance is relied upon (`with_source()` runs
|
|
368
|
+
# `_init()` a second time over the first): audit every call site before making
|
|
369
|
+
# it raise. `_initiated` is set LAST: the properties read the raw slots once set.
|
|
370
|
+
if query.__class__ is not Query:
|
|
371
|
+
query = Query.parse(query) if query else _NO_QUERY
|
|
372
|
+
fragment = fragment or ""
|
|
373
|
+
if (
|
|
374
|
+
self._initiated
|
|
375
|
+
and path == self._path
|
|
376
|
+
and fragment == self._fragment
|
|
377
|
+
and str(query) == str(self._query)
|
|
378
|
+
and source == self._source
|
|
379
|
+
):
|
|
380
|
+
# A second thread finishing its own first parse stores what is
|
|
381
|
+
# already there: resetting the caches would undo the first
|
|
382
|
+
# thread's, and a reader between the two would find them empty.
|
|
383
|
+
return
|
|
384
|
+
self._source = source
|
|
385
|
+
self._path = path
|
|
386
|
+
self._query = query
|
|
387
|
+
self._fragment = fragment
|
|
388
|
+
self._uri = None
|
|
389
|
+
self._normalized_path = None
|
|
390
|
+
self._segments_cache = None
|
|
391
|
+
self._suffix_cache = None
|
|
392
|
+
self._stem_cache = None
|
|
393
|
+
self._initiated = True
|
|
394
|
+
|
|
395
|
+
def _from_parsed_parts(
|
|
396
|
+
self, source: Source, path: str, query: str, fragment: str, /, **kwargs
|
|
397
|
+
):
|
|
398
|
+
cls = type(self)
|
|
399
|
+
uri = cls.__new__(cls)
|
|
400
|
+
uri._init(source, path, query, fragment, **kwargs)
|
|
401
|
+
return uri
|
|
402
|
+
|
|
403
|
+
def _from_decoded_path(self, path: str, /, **kwargs) -> "_ty.Self":
|
|
404
|
+
"""Build a same-type URI whose `.path` is `path` verbatim.
|
|
405
|
+
|
|
406
|
+
`path` is an **already-decoded path string**, not URI syntax: `?`,
|
|
407
|
+
`#`, `%` and `:` are ordinary filename characters here. Only
|
|
408
|
+
path-level normalization (dot segments, exactly what `_parse_uri`
|
|
409
|
+
applies *after* decoding) is performed -- nothing is split off and
|
|
410
|
+
nothing is percent-decoded.
|
|
411
|
+
|
|
412
|
+
This is what a destination/target argument must go through.
|
|
413
|
+
Feeding such a string back into the URI parser (`URI(path)`,
|
|
414
|
+
`type(self)(path)`) reads it as syntax: "a?b.txt" silently became
|
|
415
|
+
"a" plus a query, "a#b.txt" became "a" plus a fragment,
|
|
416
|
+
"a%20b.txt" became "a b.txt", and a relative "C:/Temp/x" became
|
|
417
|
+
scheme "c" plus "/Temp/x" -- so the wire call went to a different
|
|
418
|
+
file than the caller named, with no error. Percent-encoding the
|
|
419
|
+
string before parsing would fix the truncation but re-encode an
|
|
420
|
+
already-encoded name (a literal "%20" would come back as a space),
|
|
421
|
+
so the parse is bypassed instead of being fed encoded input.
|
|
422
|
+
"""
|
|
423
|
+
return self._from_parsed_parts(
|
|
424
|
+
_NOSOURCE, _remove_dot_segments(path), None, None, **kwargs
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def _is_absolute_decoded(self, path: str) -> bool:
|
|
428
|
+
"""Whether a decoded path string restarts the join. A leading "/"
|
|
429
|
+
does; `FileURIPath` also accepts a Windows drive ("C:/x")."""
|
|
430
|
+
return path.startswith("/")
|
|
431
|
+
|
|
432
|
+
def _join_decoded(self, key: str) -> "URI":
|
|
433
|
+
"""Join `key` as an already-decoded path, one segment at a time.
|
|
434
|
+
|
|
435
|
+
Each name goes through `_make_child_relpath()` -- what `iterdir()`
|
|
436
|
+
uses -- so a hand-typed name and a listed one are built by the same
|
|
437
|
+
code, including in schemes that give a name special meaning
|
|
438
|
+
(a `-` segment, say). An absolute key restarts from the root of this
|
|
439
|
+
same source. A key with `.` or `..` segments is joined as a whole
|
|
440
|
+
and has them removed as RFC 3986 5.2.4 says (`_join_dotted`). An empty
|
|
441
|
+
segment inside the key is a segment (`x//y` is `x//y`, RFC 3986 3.3):
|
|
442
|
+
the constructor and a `URI` argument keep it, and on an object store
|
|
443
|
+
`d//y` and `d/y` are two keys.
|
|
444
|
+
|
|
445
|
+
A `data:` URI is one file whose path is its payload: it has no
|
|
446
|
+
children, so a join raises (`_refuse_data`).
|
|
447
|
+
"""
|
|
448
|
+
self._refuse_data("a join")
|
|
449
|
+
if not key:
|
|
450
|
+
return self
|
|
451
|
+
segments = key.split("/")
|
|
452
|
+
absolute = self._is_absolute_decoded(key)
|
|
453
|
+
if absolute and segments[0] == "":
|
|
454
|
+
segments = segments[1:] # the root, which the join starts from
|
|
455
|
+
if "." in segments or ".." in segments:
|
|
456
|
+
return (self.with_path("/") if absolute else self)._join_dotted(segments)
|
|
457
|
+
if type(self)._make_child_relpath not in _DEFAULT_CHILD_BUILDERS:
|
|
458
|
+
return self._join_children(segments, absolute)
|
|
459
|
+
# Nothing special about a child: the path is written once.
|
|
460
|
+
path = "/" if absolute else self.path
|
|
461
|
+
has_authority = self._has_authority()
|
|
462
|
+
empty = 0
|
|
463
|
+
for segment in segments:
|
|
464
|
+
if not segment:
|
|
465
|
+
empty += 1
|
|
466
|
+
continue
|
|
467
|
+
if empty:
|
|
468
|
+
path += "/" * _empty_slashes(path, empty, True)
|
|
469
|
+
empty = 0
|
|
470
|
+
path = _child_path(path, segment, has_authority)
|
|
471
|
+
if empty:
|
|
472
|
+
# A trailing "/" is load-bearing: for http/dav it is how a
|
|
473
|
+
# directory URL is spelled, and `name`/`parent` are documented
|
|
474
|
+
# to keep it (see docs/divergences.md).
|
|
475
|
+
path += "/" * _empty_slashes(path, empty, False)
|
|
476
|
+
return self._from_parsed_parts(self.source, path, self.query, self.fragment)
|
|
477
|
+
|
|
478
|
+
def _join_children(self, segments: "list[str]", absolute: bool) -> "URI":
|
|
479
|
+
"""`_join_decoded()` for a scheme that overrides
|
|
480
|
+
`_make_child_relpath()`: it is asked for every name, one child at a
|
|
481
|
+
time, exactly as a listing asks it."""
|
|
482
|
+
result = self.with_path("/") if absolute else self
|
|
483
|
+
empty = 0
|
|
484
|
+
for segment in segments:
|
|
485
|
+
if not segment:
|
|
486
|
+
empty += 1
|
|
487
|
+
continue
|
|
488
|
+
if empty:
|
|
489
|
+
result = result._with_empty_segments(empty, between=True)
|
|
490
|
+
empty = 0
|
|
491
|
+
result = result._make_child_relpath(segment)
|
|
492
|
+
if empty:
|
|
493
|
+
result = result._with_empty_segments(empty, between=False)
|
|
494
|
+
return result
|
|
495
|
+
|
|
496
|
+
def _with_empty_segments(self, count: int, *, between: bool) -> "URI":
|
|
497
|
+
"""This URI with `count` empty segments after its path."""
|
|
498
|
+
path = self.path
|
|
499
|
+
slashes = _empty_slashes(path, count, between)
|
|
500
|
+
return self.with_path(path + "/" * slashes) if slashes > 0 else self
|
|
501
|
+
|
|
502
|
+
def _join_dotted(self, segments: "list[str]") -> "URI":
|
|
503
|
+
"""`_join_decoded()` for a key holding `.` or `..` segments.
|
|
504
|
+
|
|
505
|
+
The key is concatenated pathlib-style (`/d` + `x` is `/d/x`, whether
|
|
506
|
+
or not `/d` ends in a slash) and the dot segments of the whole path
|
|
507
|
+
are then removed, so `/d/` + `../x` is `/x` and `/d/x` + `..` is
|
|
508
|
+
`/d/`: the same answer the constructor gives for that spelling.
|
|
509
|
+
The names the key adds that survive are built as children of what
|
|
510
|
+
is left of this path."""
|
|
511
|
+
given = [segment for segment in segments if segment]
|
|
512
|
+
base = self.path or ("/" if self._has_authority() else "")
|
|
513
|
+
if base and not base.endswith("/"):
|
|
514
|
+
base += "/"
|
|
515
|
+
# A key ending in a slash, `.` or `..` names a directory.
|
|
516
|
+
trailing = segments[-1] in ("", ".", "..")
|
|
517
|
+
joined = base + "/".join(segments)
|
|
518
|
+
final = _remove_dots(joined, self.source.scheme)
|
|
519
|
+
names: list[str] = []
|
|
520
|
+
for segment in given:
|
|
521
|
+
if segment == "..":
|
|
522
|
+
if names:
|
|
523
|
+
names.pop()
|
|
524
|
+
elif segment != ".":
|
|
525
|
+
names.append(segment)
|
|
526
|
+
parts = final.split("/")
|
|
527
|
+
tail = names + [""] if trailing else names
|
|
528
|
+
if not names or parts[-len(tail) :] != tail:
|
|
529
|
+
return self.with_path(final)
|
|
530
|
+
head = "/".join(parts[: -len(tail)])
|
|
531
|
+
if not head and final.startswith("/"):
|
|
532
|
+
head = "/"
|
|
533
|
+
result = self
|
|
534
|
+
if head not in (self.path, self.path.rstrip("/")):
|
|
535
|
+
result = self.with_path(head)
|
|
536
|
+
for name in names:
|
|
537
|
+
result = result._make_child_relpath(name)
|
|
538
|
+
if trailing and not result.path.endswith("/"):
|
|
539
|
+
result = result.with_path(result.path + "/")
|
|
540
|
+
return result
|
|
541
|
+
|
|
542
|
+
def _join_arg(self, arg):
|
|
543
|
+
"""A join argument, with a `str`/`bytes` read as an already-decoded
|
|
544
|
+
path rather than URI syntax.
|
|
545
|
+
|
|
546
|
+
`/` and `joinpath()` name a child the way `iterdir()` does, so a
|
|
547
|
+
name containing `?`, `#`, `%` or a leading `C:` joins verbatim. A
|
|
548
|
+
`URI` argument keeps full URI semantics -- that is how a caller asks
|
|
549
|
+
for a scheme-aware join, e.g. `base / URI("s3://bucket/key")`.
|
|
550
|
+
"""
|
|
551
|
+
if isinstance(arg, bytes):
|
|
552
|
+
arg = arg.decode()
|
|
553
|
+
return arg
|
|
554
|
+
|
|
555
|
+
def _rename_target(self, target: URILike) -> "URI":
|
|
556
|
+
"""Normalize a `rename()`/`replace()` destination to a `URI`.
|
|
557
|
+
|
|
558
|
+
A `str` is an already-decoded path (see `_from_decoded_path`), and
|
|
559
|
+
a relative one is resolved against `self.parent` -- the documented
|
|
560
|
+
sibling-rename semantics ("rename this to a new name in the same
|
|
561
|
+
directory"), not against `self` itself. A `URI` (of any scheme
|
|
562
|
+
class) is taken as given; anything else goes through `URI(...)`,
|
|
563
|
+
which is lossless for `PurePath`/`os.PathLike` (they are
|
|
564
|
+
percent-encoded on the way in and decoded back out).
|
|
565
|
+
"""
|
|
566
|
+
if isinstance(target, URI):
|
|
567
|
+
result = target
|
|
568
|
+
else:
|
|
569
|
+
if isinstance(target, str):
|
|
570
|
+
# The join `copy()` and `move()` use for a path destination, so dot segments
|
|
571
|
+
# resolve here too: `rename("../b")` must not send a literal "sub/../b",
|
|
572
|
+
# which an object store reads as another key.
|
|
573
|
+
result = self._sibling_base()._join_decoded(target)
|
|
574
|
+
else:
|
|
575
|
+
# target is a URI by now, so this join re-uses `_load_parts`'
|
|
576
|
+
# existing right-to-left semantics without re-parsing it.
|
|
577
|
+
result = URI(self._sibling_base(), target)
|
|
578
|
+
if not self._same_location(result):
|
|
579
|
+
# Every scheme renames over its own connection or bucket using only
|
|
580
|
+
# `target.path`, so a target elsewhere would be renamed in the wrong place;
|
|
581
|
+
# NotImplementedError is move()'s signal to copy and delete instead.
|
|
582
|
+
raise NotImplementedError(
|
|
583
|
+
f"rename() cannot cross locations: {self} -> {result}"
|
|
584
|
+
)
|
|
585
|
+
return result
|
|
586
|
+
|
|
587
|
+
def _sibling_base(self) -> "URI":
|
|
588
|
+
"""The directory a relative destination is resolved against: the
|
|
589
|
+
parent without its query and fragment, which are good for this
|
|
590
|
+
resource only (a signature in the query does not sign a sibling)."""
|
|
591
|
+
parent = self.parent
|
|
592
|
+
return parent._from_parsed_parts(parent.source, parent.path, None, None)
|
|
593
|
+
|
|
594
|
+
def _same_location(self, other: "URI") -> bool:
|
|
595
|
+
"""Whether `other` lives where a native `rename()` of `self` can
|
|
596
|
+
reach it: the same endpoint (see `_same_authority`), or a sourceless
|
|
597
|
+
path relative to `self`. Schemes whose namespace is narrower than the
|
|
598
|
+
authority (an archive, an Azure container) override this."""
|
|
599
|
+
return not other.source or _same_authority(self.source, other.source)
|
|
600
|
+
|
|
601
|
+
@classmethod
|
|
602
|
+
def _format_parsed_parts(
|
|
603
|
+
cls,
|
|
604
|
+
source: Source,
|
|
605
|
+
path: str,
|
|
606
|
+
query: Query | str | None,
|
|
607
|
+
fragment: str,
|
|
608
|
+
/,
|
|
609
|
+
sanitize=True,
|
|
610
|
+
) -> str:
|
|
611
|
+
# Direct string assembly, not uricompose()'s re-validation: the parts come from
|
|
612
|
+
# a parse or from our own normalized join state (see `_compose_uri` in
|
|
613
|
+
# source/_compose.py for the equivalence notes).
|
|
614
|
+
scheme, userinfo, host, port = _source_parts(source, sanitize)
|
|
615
|
+
return _compose_uri(
|
|
616
|
+
scheme,
|
|
617
|
+
userinfo,
|
|
618
|
+
host,
|
|
619
|
+
port,
|
|
620
|
+
path,
|
|
621
|
+
str(query) if query else None,
|
|
622
|
+
fragment or None,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
def __str__(self):
|
|
626
|
+
"""Return the string representation of the path. Deliberately
|
|
627
|
+
sanitized (password dropped from userinfo) since `str()` is what
|
|
628
|
+
logging/printing reach for -- this does NOT round-trip a
|
|
629
|
+
credentialed URI. Use `as_uri(sanitize=False)` for the full URI
|
|
630
|
+
including credentials."""
|
|
631
|
+
return self.as_uri(sanitize=True)
|
|
632
|
+
|
|
633
|
+
def __fspath__(self):
|
|
634
|
+
if (self.source.scheme or "file") == "file":
|
|
635
|
+
host = self.source.host
|
|
636
|
+
if not host or (isinstance(host, str) and host.lower() == "localhost"):
|
|
637
|
+
path = self.path
|
|
638
|
+
# "file://localhost/C:/x" keeps the "/" before its drive (it
|
|
639
|
+
# must, to render with an authority); the OS path does not.
|
|
640
|
+
if (
|
|
641
|
+
os.name == "nt"
|
|
642
|
+
and path.startswith("/")
|
|
643
|
+
and _is_drive(path[1:].partition("/")[0])
|
|
644
|
+
):
|
|
645
|
+
path = path[1:]
|
|
646
|
+
return path
|
|
647
|
+
elif os.name == "nt":
|
|
648
|
+
# A named host is a UNC share even when it is this machine:
|
|
649
|
+
# "file://fileserver/share/x" is "//fileserver/share/x", never
|
|
650
|
+
# "/share/x" on the current drive. No DNS lookup either.
|
|
651
|
+
return f"//{host}/{self.path.removeprefix('/')}"
|
|
652
|
+
elif self.is_local():
|
|
653
|
+
return self.path
|
|
654
|
+
else:
|
|
655
|
+
raise NotImplementedError("OS Support for not local fspath")
|
|
656
|
+
|
|
657
|
+
if self._host_filesystem_path:
|
|
658
|
+
return self.path
|
|
659
|
+
|
|
660
|
+
raise NotImplementedError(f"fspath for {self.source.scheme}")
|
|
661
|
+
|
|
662
|
+
def host_fspath(self) -> str:
|
|
663
|
+
"""Return `.path` for any scheme whose path component is a
|
|
664
|
+
filesystem path on the URI's own host (see `_host_filesystem_path`),
|
|
665
|
+
for building a command line that runs *on that host* (e.g. via a
|
|
666
|
+
remote executor). Unlike `__fspath__`, this never falls back to
|
|
667
|
+
treating the path as local -- it raises `NotImplementedError` for
|
|
668
|
+
schemes with no host-filesystem meaning (`http:`, `s3:`, ...)."""
|
|
669
|
+
if self._host_filesystem_path:
|
|
670
|
+
return self.path
|
|
671
|
+
raise NotImplementedError(f"host_fspath for {self.source.scheme}")
|
|
672
|
+
|
|
673
|
+
def __repr__(self):
|
|
674
|
+
try:
|
|
675
|
+
return "{}({!r})".format(type(self).__name__, str(self))
|
|
676
|
+
except Exception:
|
|
677
|
+
# A URI that does not parse still has to print.
|
|
678
|
+
return super().__repr__()
|
|
679
|
+
|
|
680
|
+
def as_uri(self, /, sanitize=False):
|
|
681
|
+
if self._uri is None or sanitize:
|
|
682
|
+
uri = self._text(self.source, sanitize)
|
|
683
|
+
if not sanitize:
|
|
684
|
+
self._uri = uri
|
|
685
|
+
return uri
|
|
686
|
+
else:
|
|
687
|
+
return self._uri
|
|
688
|
+
|
|
689
|
+
def _text(self, source: Source, sanitize: bool) -> str:
|
|
690
|
+
"""The URI text of this path at `source`."""
|
|
691
|
+
path = self.path
|
|
692
|
+
if path.startswith("//") and not self._has_authority():
|
|
693
|
+
# Without an authority a "//" path would render as one ("file:////srv/x" is
|
|
694
|
+
# host "" and path "//srv/x") and str()/repr()/hash() would raise; "/." keeps
|
|
695
|
+
# it a path that parses back to the same one.
|
|
696
|
+
path = "/." + path
|
|
697
|
+
return self._format_parsed_parts(
|
|
698
|
+
source, path, self.query, self.fragment, sanitize=sanitize
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
def _identity(self) -> str:
|
|
702
|
+
"""The text that `==`, `hash()` and ordering compare: `as_uri()` with
|
|
703
|
+
the host name in lower case, since `source.host` keeps the spelling it
|
|
704
|
+
was given and a host name is case-insensitive (RFC 3986 3.2.2)."""
|
|
705
|
+
source = self.source
|
|
706
|
+
host = fold_host(source.host)
|
|
707
|
+
if host == source.host:
|
|
708
|
+
return self.as_uri()
|
|
709
|
+
return self._text(source._replace(host=host), False)
|
|
710
|
+
|
|
711
|
+
@property
|
|
712
|
+
def source(self) -> Source:
|
|
713
|
+
if not self._initiated:
|
|
714
|
+
self._load_parts()
|
|
715
|
+
return self._source
|
|
716
|
+
|
|
717
|
+
@property
|
|
718
|
+
def path(self) -> str:
|
|
719
|
+
if not self._initiated:
|
|
720
|
+
self._load_parts()
|
|
721
|
+
return self._path
|
|
722
|
+
|
|
723
|
+
@property
|
|
724
|
+
def query(self) -> Query:
|
|
725
|
+
"""The query exactly as received: still percent-encoded, so an
|
|
726
|
+
escaped `&`, `=` or `+` inside a value survives the round trip. Use
|
|
727
|
+
`Query.to_pairs()`/`to_dict()` for decoded pairs."""
|
|
728
|
+
if not self._initiated:
|
|
729
|
+
self._load_parts()
|
|
730
|
+
return self._query
|
|
731
|
+
|
|
732
|
+
@property
|
|
733
|
+
def fragment(self) -> str:
|
|
734
|
+
if not self._initiated:
|
|
735
|
+
self._load_parts()
|
|
736
|
+
return self._fragment
|
|
737
|
+
|
|
738
|
+
def _make_child_relpath(self, name: str, **kwargs) -> _ty.Self:
|
|
739
|
+
new_path = _child_path(self.path, name, self._has_authority())
|
|
740
|
+
# Through `_from_parsed_parts`, so a scheme that carries per-instance
|
|
741
|
+
# state rebuilds it (`SFTPURIPath._ssh_config`); `/` walks segments
|
|
742
|
+
# through here. The query and fragment stay, as in every path-only
|
|
743
|
+
# derivation.
|
|
744
|
+
return self._from_parsed_parts(
|
|
745
|
+
self.source, new_path, self.query, self.fragment, **kwargs
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
def with_source(self, source: Source):
|
|
749
|
+
"""Return a new URI with the source replaced."""
|
|
750
|
+
return self._from_parsed_parts(source, self.path, self.query, self.fragment)
|
|
751
|
+
|
|
752
|
+
def with_segments(self, *segments: "str | os.PathLike"):
|
|
753
|
+
"""Return a new URI with the path segments replaced.
|
|
754
|
+
|
|
755
|
+
The segments are the spelling `segments` returns (a leading `""` is
|
|
756
|
+
the root), joined with `/`; an element may also be `bytes`, a
|
|
757
|
+
`PurePath`, a `Pathname` or an `os.PathLike`, whose posix text is
|
|
758
|
+
used."""
|
|
759
|
+
self._refuse_data("with_segments()")
|
|
760
|
+
if not segments:
|
|
761
|
+
return self.with_path("")
|
|
762
|
+
return self.with_path("/".join(_segment_text(item) for item in segments))
|
|
763
|
+
|
|
764
|
+
def with_path(self, path: str | Pathname):
|
|
765
|
+
"""Return a new URI with the path replaced; under an authority a
|
|
766
|
+
relative path gets the leading `/` the constructor gives it."""
|
|
767
|
+
if not isinstance(path, str):
|
|
768
|
+
path = _segment_text(path)
|
|
769
|
+
if path and not path.startswith("/") and self._has_authority():
|
|
770
|
+
path = "/" + path
|
|
771
|
+
return self._from_parsed_parts(self.source, path, self.query, self.fragment)
|
|
772
|
+
|
|
773
|
+
def with_query(self, query: "QueryLike | None"):
|
|
774
|
+
"""Return a new URI with the query replaced.
|
|
775
|
+
|
|
776
|
+
A `str` is taken in the percent-encoded form `.query` returns and is
|
|
777
|
+
sent as given; a mapping or a sequence of pairs is encoded by
|
|
778
|
+
`Query`; `None` removes the query."""
|
|
779
|
+
query = _coerce(query)
|
|
780
|
+
return self._from_parsed_parts(self.source, self.path, query, self.fragment)
|
|
781
|
+
|
|
782
|
+
def with_fragment(self, fragment: str):
|
|
783
|
+
"""Return a new URI with the fragment replaced."""
|
|
784
|
+
return self._from_parsed_parts(self.source, self.path, self.query, fragment)
|
|
785
|
+
|
|
786
|
+
# The cached properties return the local they computed, never the slot
|
|
787
|
+
# read back: another thread's `_init` may have reset it in between.
|
|
788
|
+
@property
|
|
789
|
+
def segments(self):
|
|
790
|
+
segments = self._segments_cache
|
|
791
|
+
if segments is None:
|
|
792
|
+
self._refuse_data("the segments")
|
|
793
|
+
path = self.path
|
|
794
|
+
segments = tuple(path.split("/")) if path else ()
|
|
795
|
+
self._segments_cache = segments
|
|
796
|
+
return segments
|
|
797
|
+
|
|
798
|
+
@property
|
|
799
|
+
def suffix(self) -> str:
|
|
800
|
+
suffix = self._suffix_cache
|
|
801
|
+
if suffix is None:
|
|
802
|
+
suffix = self._suffix_cache = _name_suffix(self.name)
|
|
803
|
+
return suffix
|
|
804
|
+
|
|
805
|
+
@property
|
|
806
|
+
def stem(self) -> str:
|
|
807
|
+
stem = self._stem_cache
|
|
808
|
+
if stem is None:
|
|
809
|
+
stem = self._stem_cache = _name_stem(self.name)
|
|
810
|
+
return stem
|
|
811
|
+
|
|
812
|
+
@property
|
|
813
|
+
def parent(self):
|
|
814
|
+
"""The logical parent of the path."""
|
|
815
|
+
segments = self.segments
|
|
816
|
+
if not segments or len(segments) == 2 and segments[1] == "":
|
|
817
|
+
return self
|
|
818
|
+
if len(segments) == 2 and segments[0] == "":
|
|
819
|
+
# "/a" -> "/", not "": the empty path is relative, so the parent
|
|
820
|
+
# of a top-level FileURIPath resolved to the current directory and
|
|
821
|
+
# `parents` never reached the root.
|
|
822
|
+
return self.with_path("/")
|
|
823
|
+
return self.with_path("/".join(segments[:-1]))
|
|
824
|
+
|
|
825
|
+
def _has_authority(self) -> bool:
|
|
826
|
+
return _authority_of(self.source)
|
|
827
|
+
|
|
828
|
+
def _refuse_data(self, what: str) -> None:
|
|
829
|
+
"""Raise `NotImplementedError` if this is a `data:` URI. Its path is
|
|
830
|
+
the payload (RFC 2397), not a hierarchy: it has no names, parents or
|
|
831
|
+
children, and slicing it at a `/` would make another file."""
|
|
832
|
+
if self.source.scheme == "data":
|
|
833
|
+
raise NotImplementedError(
|
|
834
|
+
f"{what} of a data: URI: its path is the payload of a single "
|
|
835
|
+
"file, with no names, parents or children"
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
def _match_parts(self) -> tuple[bool, list[str]]:
|
|
839
|
+
# An authority with an empty path ("http://h") is that authority's
|
|
840
|
+
# root, the same as "http://h/" (RFC 3986). The host itself is never
|
|
841
|
+
# part of what match() sees.
|
|
842
|
+
anchored, names = super()._match_parts()
|
|
843
|
+
return anchored or (not self.path and self._has_authority()), names
|
|
844
|
+
|
|
845
|
+
def _prefix_segments(self) -> list[str]:
|
|
846
|
+
"""Segments for `is_relative_to`/`relative_to` prefix comparison:
|
|
847
|
+
`[""]` for a root (including an authority with an empty path), `[]` for
|
|
848
|
+
the empty relative path."""
|
|
849
|
+
if not self.path:
|
|
850
|
+
return [""] if self._has_authority() else []
|
|
851
|
+
path = self.normalized_path
|
|
852
|
+
return [] if path == "." else _segments_of(path)
|
|
853
|
+
|
|
854
|
+
@property
|
|
855
|
+
def normalized_path(self):
|
|
856
|
+
"""Return the normalized path using posixpath rules."""
|
|
857
|
+
if self._normalized_path is None:
|
|
858
|
+
self._normalized_path = _posix.normpath(self.path)
|
|
859
|
+
return self._normalized_path
|
|
860
|
+
|
|
861
|
+
def is_absolute(self):
|
|
862
|
+
"""True if the path is absolute: it starts with "/", with or without
|
|
863
|
+
a source (as `PurePosixPath("/a")` is absolute)."""
|
|
864
|
+
return self.path.startswith("/")
|
|
865
|
+
|
|
866
|
+
def is_relative_to(self, other: URILike):
|
|
867
|
+
"""Return True if the path is relative to another path or False."""
|
|
868
|
+
self._refuse_data("is_relative_to()")
|
|
869
|
+
# `other` is parsed standalone (`URI(other)`), not anchored at self's root, as
|
|
870
|
+
# `relative_to()` and CPython do. A standalone parse leaves `other.source`
|
|
871
|
+
# empty, which the guard below treats as compatible with any `self.source`.
|
|
872
|
+
other = other if isinstance(other, URI) else URI(other)
|
|
873
|
+
if not (
|
|
874
|
+
_same_authority(other.source, self.source)
|
|
875
|
+
or not (bool(self.source) and bool(other.source))
|
|
876
|
+
):
|
|
877
|
+
return False
|
|
878
|
+
# Segment-wise prefix comparison: a naive startswith() on the raw
|
|
879
|
+
# strings would report "/foo/bar2" as relative to "/foo/bar".
|
|
880
|
+
_other = other._prefix_segments()
|
|
881
|
+
_self = self._prefix_segments()
|
|
882
|
+
if _self[:1] == [""] and _other[:1] != [""]:
|
|
883
|
+
# An absolute path is never relative to a relative one (the
|
|
884
|
+
# empty relative path included), as in pathlib.
|
|
885
|
+
return False
|
|
886
|
+
return _self[: len(_other)] == _other
|
|
887
|
+
|
|
888
|
+
def relative_to(self, other: URILike, *, walk_up=False):
|
|
889
|
+
self._refuse_data("relative_to()")
|
|
890
|
+
other = other if isinstance(other, URI) else URI(other)
|
|
891
|
+
# No upfront `is_relative_to()` check: the step==0 iteration (path=other)
|
|
892
|
+
# raises the same error, and an early one would skip the walk_up loop.
|
|
893
|
+
for step, path in enumerate([other] + list(other.parents)):
|
|
894
|
+
if self.is_relative_to(path):
|
|
895
|
+
break
|
|
896
|
+
elif not walk_up:
|
|
897
|
+
raise ValueError(
|
|
898
|
+
f"{str(self)!r} is not in the subpath of {str(other)!r}"
|
|
899
|
+
)
|
|
900
|
+
elif path.name == "..":
|
|
901
|
+
raise ValueError(f"'..' segment in {str(other)!r} cannot be walked")
|
|
902
|
+
else:
|
|
903
|
+
raise ValueError(f"{str(self)!r} and {str(other)!r} have different anchors")
|
|
904
|
+
# `_prefix_segments()`, not raw `.segments`: the latter gives the URI root ("/")
|
|
905
|
+
# a spurious 2-tuple ("", ""), and `relative_to(<root>)` would drop the
|
|
906
|
+
# child's only real segment.
|
|
907
|
+
self_segs = self._prefix_segments()
|
|
908
|
+
path_segs = path._prefix_segments()
|
|
909
|
+
parts = [".."] * step + self_segs[len(path_segs) :]
|
|
910
|
+
return self._from_parsed_parts(
|
|
911
|
+
_NOSOURCE, "/".join(parts), self.query, self.fragment
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
def is_local(self):
|
|
915
|
+
"""Return True if the URI points to a local resource."""
|
|
916
|
+
return self.source.is_local()
|
|
917
|
+
|
|
918
|
+
def __eq__(self, other: URI | str):
|
|
919
|
+
# Only a URI or a URI string, read as one so that the text is compared by
|
|
920
|
+
# what it names and not by its spelling; another Pathname (MemPath,
|
|
921
|
+
# LocalPath) hashes by its own rule, and a relative LocalPath's as_uri()
|
|
922
|
+
# raises. Text that is not a URI names nothing, so it is not equal.
|
|
923
|
+
if isinstance(other, str):
|
|
924
|
+
try:
|
|
925
|
+
uri = URI(other)._identity()
|
|
926
|
+
except ValueError:
|
|
927
|
+
return False
|
|
928
|
+
elif isinstance(other, URI):
|
|
929
|
+
uri = other._identity()
|
|
930
|
+
else:
|
|
931
|
+
return NotImplemented
|
|
932
|
+
return self._identity() == uri
|
|
933
|
+
|
|
934
|
+
def __hash__(self):
|
|
935
|
+
return hash(self._identity())
|
|
936
|
+
|
|
937
|
+
def _order_key(self):
|
|
938
|
+
return self._identity()
|
|
939
|
+
|
|
940
|
+
def _join_object(self, *parts) -> "URI":
|
|
941
|
+
"""A new URI of this class from `parts`, joined as the constructor
|
|
942
|
+
joins them. `URIPath` also chooses the class from the result's
|
|
943
|
+
scheme, so joining an absolute local path gives a `FileURIPath`."""
|
|
944
|
+
return type(self)(*parts)
|
|
945
|
+
|
|
946
|
+
def __truediv__(self, key):
|
|
947
|
+
"""`uri / "name"`. A `str` is a decoded path segment, never URI
|
|
948
|
+
syntax: "a?b.txt" is a filename, not a query (`URI._join_arg`). Any
|
|
949
|
+
other argument is joined as the constructor joins it."""
|
|
950
|
+
# Only converting `key` decides NotImplemented: catching TypeError
|
|
951
|
+
# around the whole construction would turn a bug inside a scheme's
|
|
952
|
+
# __new__/_init into "unsupported operand type(s) for /".
|
|
953
|
+
self._refuse_data("a join")
|
|
954
|
+
key = self._join_arg(key)
|
|
955
|
+
if isinstance(key, str):
|
|
956
|
+
return self._join_decoded(key)
|
|
957
|
+
try:
|
|
958
|
+
converted = _as_uri(key)
|
|
959
|
+
except (TypeError, NotImplementedError):
|
|
960
|
+
return NotImplemented
|
|
961
|
+
return self._join_object(self, *converted._raw_uris)
|
|
962
|
+
|
|
963
|
+
def joinpath(self, *args):
|
|
964
|
+
"""Combine this URI with segments; a `str` is a decoded path (see
|
|
965
|
+
`URI._join_arg`), anything else is joined as the constructor joins
|
|
966
|
+
it -- pass a `URI` for a scheme-aware join."""
|
|
967
|
+
self._refuse_data("a join")
|
|
968
|
+
result = self
|
|
969
|
+
for arg in args:
|
|
970
|
+
arg = result._join_arg(arg)
|
|
971
|
+
if isinstance(arg, str):
|
|
972
|
+
result = result._join_decoded(arg)
|
|
973
|
+
else:
|
|
974
|
+
result = result._join_object(result, *_as_uri(arg)._raw_uris)
|
|
975
|
+
return result
|
|
976
|
+
|
|
977
|
+
def __rtruediv__(self, key: str):
|
|
978
|
+
"""`"prefix" / uri`. The str is a decoded path joined in front of
|
|
979
|
+
this one, never URI syntax (so "C:/x" is not read as a scheme)."""
|
|
980
|
+
if not isinstance(key, str):
|
|
981
|
+
return NotImplemented
|
|
982
|
+
self._refuse_data("a join")
|
|
983
|
+
# A plain URI: building the prefix must not touch `self.backend`.
|
|
984
|
+
return self._join_object(URI()._from_decoded_path(key), self)
|
|
985
|
+
|
|
986
|
+
def as_posix(self):
|
|
987
|
+
source = self.source
|
|
988
|
+
host = None
|
|
989
|
+
posix = self.path
|
|
990
|
+
if source.host:
|
|
991
|
+
host = source.host
|
|
992
|
+
user, password = source.parsed_userinfo()
|
|
993
|
+
if user:
|
|
994
|
+
posix = f"{user}@{host}:{posix}"
|
|
995
|
+
else:
|
|
996
|
+
posix = f"{host}:{posix}"
|
|
997
|
+
return posix
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
URI._slot_names = _slot_names(URI)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
#: The `_make_child_relpath` implementations that only write a child's path:
|
|
1004
|
+
#: a class with any other builds a joined key one child at a time
|
|
1005
|
+
#: (`URI._join_children`). The module that defines a class with such a builder
|
|
1006
|
+
#: adds it.
|
|
1007
|
+
_DEFAULT_CHILD_BUILDERS = {URI._make_child_relpath}
|