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.
Files changed (48) hide show
  1. pathapi_uri-0.1.0.dist-info/METADATA +259 -0
  2. pathapi_uri-0.1.0.dist-info/RECORD +48 -0
  3. pathapi_uri-0.1.0.dist-info/WHEEL +4 -0
  4. pathapi_uri-0.1.0.dist-info/entry_points.txt +17 -0
  5. pathapi_uri-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. uripath/AGENTS.md +1094 -0
  7. uripath/README.md +213 -0
  8. uripath/__init__.py +27 -0
  9. uripath/__main__.py +7 -0
  10. uripath/_pathtext.py +32 -0
  11. uripath/_rfc3986.py +67 -0
  12. uripath/_transfer.py +108 -0
  13. uripath/_uri.py +1007 -0
  14. uripath/_version.py +11 -0
  15. uripath/cli/__init__.py +54 -0
  16. uripath/cli/_common.py +48 -0
  17. uripath/cli/_root.py +70 -0
  18. uripath/cli/cp.py +61 -0
  19. uripath/cli/read.py +22 -0
  20. uripath/cli/rm.py +38 -0
  21. uripath/cli/sync.py +78 -0
  22. uripath/cli/write.py +49 -0
  23. uripath/exceptions.py +38 -0
  24. uripath/py.typed +1 -0
  25. uripath/query.py +240 -0
  26. uripath/schemes/__init__.py +120 -0
  27. uripath/schemes/_archive.py +338 -0
  28. uripath/schemes/_data.py +99 -0
  29. uripath/schemes/_dav.py +459 -0
  30. uripath/schemes/_delegate.py +127 -0
  31. uripath/schemes/_elsewhere.py +102 -0
  32. uripath/schemes/_file.py +186 -0
  33. uripath/schemes/_ftp.py +200 -0
  34. uripath/schemes/_http/__init__.py +4 -0
  35. uripath/schemes/_http/_backend.py +132 -0
  36. uripath/schemes/_http/_headers.py +8 -0
  37. uripath/schemes/_http/_listing.py +403 -0
  38. uripath/schemes/_http/_path.py +371 -0
  39. uripath/schemes/_http/_settings.py +6 -0
  40. uripath/schemes/_http/_streams.py +230 -0
  41. uripath/schemes/_httpdate.py +48 -0
  42. uripath/schemes/_path.py +629 -0
  43. uripath/schemes/_registry.py +134 -0
  44. uripath/source/__init__.py +20 -0
  45. uripath/source/_compose.py +212 -0
  46. uripath/source/_locality.py +44 -0
  47. uripath/source/_parse.py +183 -0
  48. 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}