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
@@ -0,0 +1,629 @@
1
+ """`URIPath`: the class a URI scheme subclasses, and the backend bookkeeping it shares between the paths of one endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat as _stat
6
+ import threading as _threading
7
+ import typing as _ty
8
+ import weakref as _weakref
9
+ import re as _re
10
+
11
+ from pathapi import FileStat, Path
12
+ from pathapi import providers as _providers
13
+ from .._uri import URI, URILike
14
+ from .._uri import _DEFAULT_CHILD_BUILDERS
15
+ from ..exceptions import UnsupportedSchemeError
16
+ from ..source import Source
17
+ from ..source._source import _same_authority
18
+ from ._registry import (
19
+ _BUILTIN_SCHEMES,
20
+ _CLAIMS,
21
+ _CLAIMS_LOCK,
22
+ _ENTRY_POINTS_LOADED,
23
+ _claim_schemes,
24
+ _declared_entry_points,
25
+ _forget_entry_points,
26
+ )
27
+
28
+ if _ty.TYPE_CHECKING:
29
+ from typing import Self
30
+
31
+
32
+ #: An RFC 3986 scheme followed by its colon, used to tell a URI destination
33
+ #: from a path one (see `_looks_like_uri`, which `as_path()` also applies).
34
+ _URI_SCHEME_RE = _re.compile(r"[A-Za-z][A-Za-z0-9+.\-]*:")
35
+
36
+
37
+ #: Backends a path built for itself through `_initbackend()`, by `id()` (the `is`
38
+ #: test on lookup makes a reused id harmless). It covers a derived backend that
39
+ #: a scheme hands on through `backend=`, which would read as supplied.
40
+ _DERIVED_BACKENDS: "_weakref.WeakValueDictionary[int, object]" = (
41
+ _weakref.WeakValueDictionary()
42
+ )
43
+
44
+
45
+ class DerivedBackend:
46
+ """Marks a backend class whose instances only ever come from
47
+ `_initbackend()`, for a backend that cannot be weakly referenced and so
48
+ cannot be recorded in `_DERIVED_BACKENDS` (a tuple, say)."""
49
+
50
+ __slots__ = ()
51
+
52
+
53
+ _DerivedBackend = DerivedBackend
54
+
55
+
56
+ class _BackendCell:
57
+ """The slot that paths derived from one another on one endpoint share for
58
+ the backend they derive for themselves. It starts empty, so deriving a
59
+ path builds nothing; the first of them to need a backend fills it and the
60
+ rest read it. `lock` makes two threads that ask at once build one."""
61
+
62
+ __slots__ = ("backend", "lock")
63
+
64
+ def __init__(self):
65
+ self.backend = None
66
+ self.lock = _threading.RLock()
67
+
68
+
69
+ #: Makes "create the cell of a path unless it has one" atomic.
70
+ _CELL_LOCK = _threading.Lock()
71
+
72
+
73
+ def _rebuild_path(cls, uri, options, backend, schemesmap):
74
+ """Unpickle a `URIPath`: the class built from its URI text again."""
75
+ if backend is not None:
76
+ options = {**options, "backend": backend}
77
+ if schemesmap is not None:
78
+ options = {**options, "schemesmap": schemesmap}
79
+ return cls(uri, **options)
80
+
81
+
82
+ def _looks_like_uri(value: str, schemesmap=None) -> bool:
83
+ """Whether a destination string is URI syntax rather than a path.
84
+
85
+ Needs an RFC 3986 scheme prefix; without `://` the scheme must also be
86
+ one a class registers, so an ordinary relative name whose first segment
87
+ happens to carry a colon (`notes:draft`, `Fedora-42:latest.tar`) stays a
88
+ path. A one-letter scheme is a Windows drive (`C:/Temp`). `as_path()`
89
+ applies the same rule to a path given as text.
90
+ `schemesmap` is the allow-list the scheme is looked up in, if any.
91
+ """
92
+ match = _URI_SCHEME_RE.match(value)
93
+ if match is None:
94
+ return False
95
+ if match.end() == 2: # "C:" -- a drive, not a scheme
96
+ return False
97
+ if "://" in value:
98
+ return True
99
+ scheme = value[: match.end() - 1].lower()
100
+ try:
101
+ return (
102
+ Source(scheme, None, None, None).get_scheme_cls(schemesmap) is not URIPath
103
+ )
104
+ except Exception:
105
+ # A scheme plugin that fails to load: treat it as a URI, as the
106
+ # scheme lookup would have.
107
+ return True
108
+
109
+
110
+ class URIPath(URI, Path):
111
+ """`URI` + `Path` (I/O) + scheme dispatch. `URIPath(...)` constructs
112
+ the concrete subclass registered for the URI's scheme (via `__SCHEMES`)
113
+ -- e.g. `URIPath("http://...")` returns an `HTTPURIPath`. Subclass this
114
+ and set `__SCHEMES` to add a new scheme (Track B of extending this
115
+ library; see `docs/guides/extending.md`): defining the class registers
116
+ it, a name another class holds raises `ValueError` unless the class
117
+ statement passes `replace=True`, and `schemes()` / `scheme_class()` read
118
+ the registry. Implement the I/O surface
119
+ (`_listdir` or `_scandir`, `stat`, `_open`, ...) documented in
120
+ `docs/guides/extending.md`. Prefer overriding `_scandir()` over `_listdir()` when the
121
+ listing call already returns type/size/mtime metadata (PROPFIND, MLSD,
122
+ `listdir_attr`, an S3 list page, ...) -- `walk()`/`glob()` then answer
123
+ `is_dir()` on the results for free, without a stat request per entry."""
124
+
125
+ #: Slots: `_backend` (None until built or inherited), `_backend_derived` (the path
126
+ #: built it), `_backend_candidates` (join segments' backends until the endpoint is
127
+ #: known), `_derived_cell` (shared on one endpoint), `_schemes_in_use` (`schemesmap=`).
128
+ __slots__ = (
129
+ "_backend",
130
+ "_backend_derived",
131
+ "_backend_candidates",
132
+ "_derived_cell",
133
+ "_stat_hint",
134
+ "_schemes_in_use",
135
+ )
136
+ __SCHEMES: _ty.Sequence[str] = ()
137
+ #: A scheme that takes keyword arguments of its own adds their names.
138
+ _OPTIONS = frozenset({"backend", "schemesmap", "findclass"})
139
+
140
+ def __init_subclass__(cls, replace: bool = False, **kwargs):
141
+ super().__init_subclass__(**kwargs)
142
+ _claim_schemes(cls, replace)
143
+ _forget_entry_points()
144
+
145
+ @classmethod
146
+ def schemes(cls) -> "tuple[str, ...]":
147
+ """The registered scheme names, sorted: those of every class defined
148
+ so far, every built-in scheme and every entry point of the
149
+ `uripath.schemes` group, whether or not its module is imported
150
+ yet. The schemes another distribution provides are listed too."""
151
+ from . import _elsewhere
152
+
153
+ with _CLAIMS_LOCK:
154
+ claimed = list(_CLAIMS)
155
+ return tuple(
156
+ sorted(
157
+ {
158
+ *claimed,
159
+ *_BUILTIN_SCHEMES,
160
+ *_elsewhere.SCHEMES,
161
+ *_declared_entry_points(),
162
+ }
163
+ )
164
+ )
165
+
166
+ @classmethod
167
+ def scheme_class(cls, name: str) -> "type[URIPath]":
168
+ """The class registered for the scheme `name` (case-insensitive),
169
+ importing its module or loading its entry point first when needed.
170
+
171
+ Raises `UnsupportedSchemeError` naming the registered schemes when there is
172
+ none, and `ImportError` for a built-in module that cannot be
173
+ imported and for a scheme another distribution provides that is not
174
+ installed (the error names what to install).
175
+ """
176
+ found = cls._find_scheme(name.lower(), strict=True)
177
+ if found is None:
178
+ raise UnsupportedSchemeError(
179
+ f"no URI scheme {name!r} is registered; registered: "
180
+ + ", ".join(cls.schemes())
181
+ )
182
+ return found
183
+
184
+ @classmethod
185
+ def _find_scheme(cls, scheme: str, strict: bool = False):
186
+ """The class that registered `scheme`, or `None`: a class already
187
+ defined, then the built-in module, then an entry point, then the class
188
+ that stands for a scheme another distribution provides. With `strict`
189
+ a built-in module that fails to import, and a scheme provided
190
+ elsewhere that is not installed, raise instead of reading as "no
191
+ class" and as that stand-in."""
192
+ found = _CLAIMS.get(scheme)
193
+ if found is None and (
194
+ cls._load_builtin_scheme(scheme, strict=strict)
195
+ or cls._load_entry_point(scheme)
196
+ ):
197
+ found = _CLAIMS.get(scheme)
198
+ if found is None:
199
+ from . import _elsewhere
200
+
201
+ if scheme not in _elsewhere.SCHEMES:
202
+ return None
203
+ if strict:
204
+ raise _elsewhere.not_installed(scheme)
205
+ found = _elsewhere.ProvidedElsewhere
206
+ return found
207
+
208
+ @classmethod
209
+ def _schemes(cls) -> _ty.Sequence[str]:
210
+ try:
211
+ return getattr(cls, f"_{cls.__name__}__SCHEMES")
212
+ except AttributeError:
213
+ return ()
214
+
215
+ @classmethod
216
+ def _load_entry_point(cls, scheme: str) -> bool:
217
+ """Attempt to load a scheme class from package entry points.
218
+
219
+ Looks for entry points in the 'uripath.schemes' group where
220
+ the name matches the requested scheme.
221
+ """
222
+ ep = _declared_entry_points().get(scheme)
223
+ if ep is None or scheme in _ENTRY_POINTS_LOADED:
224
+ return False
225
+ ep.load()
226
+ _ENTRY_POINTS_LOADED.add(scheme)
227
+ return True
228
+
229
+ @classmethod
230
+ def _load_builtin_scheme(cls, scheme: str, strict: bool = False) -> bool:
231
+ """Attempt to load a builtin scheme module dynamically on-demand."""
232
+ module_name = _BUILTIN_SCHEMES.get(scheme)
233
+ if module_name:
234
+ import importlib
235
+
236
+ try:
237
+ importlib.import_module(module_name)
238
+ return True
239
+ except ImportError:
240
+ if strict:
241
+ raise
242
+ return False
243
+
244
+ def __new__(
245
+ cls,
246
+ *args,
247
+ schemesmap: "_ty.Mapping[str, type[URIPath]] | None" = None,
248
+ findclass=False,
249
+ **kwargs,
250
+ ) -> "URIPath":
251
+ if cls is URIPath or findclass:
252
+ uri = URI(*args)
253
+ cls: type[URIPath] = uri.source.get_scheme_cls(schemesmap)
254
+ if cls is URIPath:
255
+ inst = URI.__new__(cls, *args, **kwargs)
256
+ else:
257
+ inst = cls.__new__(cls, *args, **kwargs)
258
+ inst._schemes_in_use = schemesmap
259
+ inst._init(uri.source, uri.path, uri.query, uri.fragment, **kwargs)
260
+ else:
261
+ inst = URI.__new__(cls, *args, **kwargs)
262
+ inst._schemes_in_use = schemesmap
263
+ backend = kwargs.get("backend", None)
264
+ if backend is not None:
265
+ inst._backend = backend
266
+ else:
267
+ # Which of these a path inherits depends on the endpoint it
268
+ # ends up with, known once it is parsed (`_inherit_backend`).
269
+ for segment in args:
270
+ if isinstance(segment, cls):
271
+ if segment._backend_candidates is not None:
272
+ # Parsing the segment settles its own inheritance.
273
+ segment._load_parts()
274
+ if inst._backend_candidates is None:
275
+ inst._backend_candidates = []
276
+ inst._backend_candidates.append(
277
+ (segment, segment._backend, segment._backend_derived)
278
+ )
279
+ return inst
280
+
281
+ def _initbackend(self):
282
+ return None
283
+
284
+ def _inherit_backend(self, source: Source) -> None:
285
+ """Settle which join segment's backend this path takes now that its
286
+ endpoint is `source`: the rightmost one that holds a backend and
287
+ belongs to that same endpoint (scheme, userinfo, host, port), else
288
+ none. A backend never crosses to another endpoint, so credentials and
289
+ sessions stay put. With none, the path joins the family of the
290
+ rightmost segment on its endpoint (`_family_for()`)."""
291
+ candidates = self._backend_candidates
292
+ if candidates is None:
293
+ return
294
+ family = None
295
+ for segment, backend, derived in reversed(candidates):
296
+ if not _same_authority(segment.source, source):
297
+ continue
298
+ if backend is not None:
299
+ self._backend, self._backend_derived = backend, derived
300
+ family = None
301
+ break
302
+ if family is None and source:
303
+ family = segment._family()
304
+ if family is not None:
305
+ self._derived_cell = family
306
+ self._backend_candidates = None
307
+
308
+ def _family(self) -> _BackendCell:
309
+ """The cell this path and the paths derived from it share, made when
310
+ it is first asked for."""
311
+ cell = self._derived_cell
312
+ if cell is None:
313
+ with _CELL_LOCK:
314
+ cell = self._derived_cell
315
+ if cell is None:
316
+ cell = self._derived_cell = _BackendCell()
317
+ if self._backend is not None and self._backend_derived:
318
+ # Already built on its own: the family reads that one.
319
+ cell.backend = self._backend
320
+ return cell
321
+
322
+ def _family_for(self, source: Source) -> "_BackendCell | None":
323
+ """The cell a path at `source` shares with this one: this path's own
324
+ when `source` is its endpoint, else none. Never builds a backend, and
325
+ a path that holds one hands that on instead (`_backend_for()`)."""
326
+ if not self._initiated:
327
+ self._load_parts()
328
+ if self._backend is not None:
329
+ return None
330
+ mine = self._source
331
+ if mine and (source is mine or _same_authority(source, mine)):
332
+ return self._family()
333
+ return None
334
+
335
+ def _backend_for(self, source: Source):
336
+ """`(backend, derived)` a path at `source` inherits from this one:
337
+ what this path already holds, if `source` is its own endpoint, else
338
+ `(None, None)`. Never builds a backend."""
339
+ if not self._initiated:
340
+ self._load_parts()
341
+ backend = self._backend
342
+ if backend is not None:
343
+ mine = self._source
344
+ if source is mine or _same_authority(source, mine):
345
+ return backend, self._backend_derived
346
+ return None, None
347
+
348
+ def _from_parsed_parts(
349
+ self, source: Source, path: str, query: str, fragment: str, /, **kwargs
350
+ ):
351
+ if "backend" in kwargs or (
352
+ self._backend is None and self._backend_candidates is None
353
+ ):
354
+ # Given, or nothing to carry: the usual case before any I/O.
355
+ inst = super()._from_parsed_parts(source, path, query, fragment, **kwargs)
356
+ if "backend" not in kwargs:
357
+ # A path that already has a family and stays on its own
358
+ # source hands the cell on without asking again.
359
+ cell = self._derived_cell
360
+ inst._derived_cell = (
361
+ cell
362
+ if cell is not None and source is self._source
363
+ else self._family_for(source)
364
+ )
365
+ else:
366
+ backend, derived = self._backend_for(source)
367
+ if backend is not None:
368
+ kwargs["backend"] = backend
369
+ inst = super()._from_parsed_parts(source, path, query, fragment, **kwargs)
370
+ if backend is not None and inst._backend is backend:
371
+ inst._backend_derived = derived
372
+ elif backend is None:
373
+ inst._derived_cell = self._family_for(source)
374
+ inst._schemes_in_use = self._schemes_in_use
375
+ return inst
376
+
377
+ def _init(
378
+ self,
379
+ source: Source,
380
+ path: str,
381
+ query: str,
382
+ fragment: str,
383
+ /,
384
+ backend=None,
385
+ **kwargs,
386
+ ):
387
+ self._inherit_backend(source)
388
+ if backend is not None:
389
+ self._backend = backend
390
+ self._backend_derived = None
391
+ super()._init(source, path, query, fragment, **kwargs)
392
+
393
+ @property
394
+ def backend(self):
395
+ """The connection or session state backend instance."""
396
+ if not self._initiated:
397
+ # Settles a backend inherited from a join segment first.
398
+ self._load_parts()
399
+ backend = self._backend
400
+ if backend is None:
401
+ cell = self._derived_cell
402
+ built = False
403
+ if cell is None:
404
+ backend = self._initbackend()
405
+ built = backend is not None
406
+ else:
407
+ # The family builds one, whichever of them asks first.
408
+ with cell.lock:
409
+ backend = cell.backend
410
+ if backend is None:
411
+ backend = self._initbackend()
412
+ if backend is not None:
413
+ cell.backend = backend
414
+ built = True
415
+ if backend is not None:
416
+ self._backend = backend
417
+ self._backend_derived = True
418
+ if built:
419
+ try:
420
+ _DERIVED_BACKENDS[id(backend)] = backend
421
+ except TypeError:
422
+ pass
423
+ return backend
424
+
425
+ def _supplied_backend(self):
426
+ """The backend the caller gave this path (`backend=`,
427
+ `with_backend()`, or inherited from a path that had one), or None
428
+ when it has none yet or derived its own. Never builds one."""
429
+ backend = self._backend
430
+ if (
431
+ backend is None
432
+ or self._backend_derived
433
+ or isinstance(backend, _DerivedBackend)
434
+ or _DERIVED_BACKENDS.get(id(backend)) is backend
435
+ ):
436
+ return None
437
+ return backend
438
+
439
+ def _same_filesystem(self, other: "URIPath") -> bool:
440
+ # A URI's equality includes its authority, so two paths built separately for one
441
+ # URL are the same file; only two distinct SUPPLIED backends (separate
442
+ # connections, or fakes for two hosts) are not provably the same tree.
443
+ mine, theirs = self._supplied_backend(), other._supplied_backend()
444
+ return mine is None or theirs is None or mine is theirs
445
+
446
+ def _rename_compatible(self, target) -> bool:
447
+ # Every scheme's `rename()` reads its target through
448
+ # `_rename_target()`, which raises NotImplementedError for one it
449
+ # cannot reach, so `move()` falls back to copy + delete by itself.
450
+ return True
451
+
452
+ def _coerce_target(self, target: str) -> "URIPath":
453
+ """A `str` destination for `copy()`/`move()`, read by its shape.
454
+
455
+ With a scheme (`s3://bucket/key`, `file:///tmp/x`) it is a URI, so a
456
+ cross-scheme copy keeps working. Without one it is a decoded path on
457
+ THIS endpoint -- absolute replaces the path, relative is a sibling,
458
+ as `rename()` resolves it -- keeping this path's source and backend
459
+ instead of building a sourceless path plus a second connection.
460
+
461
+ A single-letter scheme is a Windows drive, not a scheme: `C:/Temp/x`
462
+ is a path (the same rule as `as_path()`).
463
+ """
464
+ if _looks_like_uri(target, self._schemes_in_use):
465
+ destination = type(self)(
466
+ target, findclass=True, schemesmap=self._schemes_in_use
467
+ )
468
+ # Same endpoint: reuse the connection (and whatever auth was
469
+ # configured on it) rather than opening a second, bare one.
470
+ backend, derived = self._backend_for(destination.source)
471
+ if backend is not None:
472
+ destination._backend, destination._backend_derived = backend, derived
473
+ else:
474
+ destination._derived_cell = self._family_for(destination.source)
475
+ return destination
476
+ # `/` joins a decoded path on this endpoint and carries the backend; the
477
+ # destination is another resource, so it takes no query or fragment.
478
+ return self._sibling_base() / target
479
+
480
+ def with_backend(self, backend):
481
+ """Return a new path instance sharing the same backend state."""
482
+ return self._from_parsed_parts(*self.parts, backend=backend)
483
+
484
+ def _reduce_options(self) -> dict:
485
+ """Constructor keywords a pickle rebuilds this path with, beside its
486
+ URI text. A scheme with per-path state that is not a secret returns
487
+ it here; the backend and the stat hint are never part of it."""
488
+ return {}
489
+
490
+ def __reduce__(self):
491
+ # A path pickles as its URI text (userinfo included), never its connection. A
492
+ # supplied backend goes along only if it declares `picklable = True`; a
493
+ # derived one is rebuilt on demand.
494
+ backend = self._supplied_backend()
495
+ if backend is not None and getattr(backend, "picklable", False) is not True:
496
+ backend = None
497
+ return (
498
+ _rebuild_path,
499
+ (
500
+ type(self),
501
+ self.as_uri(sanitize=False),
502
+ self._reduce_options(),
503
+ backend,
504
+ self._schemes_in_use,
505
+ ),
506
+ )
507
+
508
+ def __copy__(self):
509
+ # The same URI on the same backend object, supplied or derived: a
510
+ # copy must not open a second connection or change which paths
511
+ # `_same_filesystem()` places together.
512
+ return self._from_parsed_parts(*self.parts)
513
+
514
+ def __deepcopy__(self, memo):
515
+ # A connection is not part of the value, so it is shared here too.
516
+ inst = memo[id(self)] = self.__copy__()
517
+ return inst
518
+
519
+ def _join_object(self, *parts) -> "URIPath":
520
+ # The class comes from the joined scheme, and from the allow-list.
521
+ return type(self)(*parts, findclass=True, schemesmap=self._schemes_in_use)
522
+
523
+ def with_source(self, source: Source):
524
+ cls = type(self)
525
+ if not source or not source.scheme:
526
+ # Nothing to dispatch on (`source.scheme + ":"` raised TypeError
527
+ # for a host-only Source): a plain URIPath.
528
+ inst = URI.__new__(URIPath)
529
+ elif source.scheme not in cls._schemes():
530
+ inst = cls.__new__(
531
+ cls,
532
+ source.scheme + ":",
533
+ findclass=True,
534
+ schemesmap=self._schemes_in_use,
535
+ )
536
+ else:
537
+ inst = cls.__new__(cls)
538
+ backend, derived = self._backend_for(source)
539
+ if backend is not None:
540
+ inst._backend, inst._backend_derived = backend, derived
541
+ else:
542
+ inst._derived_cell = self._family_for(source)
543
+ inst._schemes_in_use = self._schemes_in_use
544
+ inst._init(source, self.path, self.query, self.fragment)
545
+ return inst
546
+
547
+ def _symlink_target(self, target: "URILike") -> "_ty.Self":
548
+ """`Path._symlink_target()` for URI-backed paths: a `str` target is
549
+ an already-decoded path, never URI syntax (see
550
+ `URI._from_decoded_path`).
551
+
552
+ The default `type(self)(target)` ran the link target back through
553
+ the URI parser, so `symlink_to("/mnt/cache?v=2")` created a link
554
+ pointing at `/mnt/cache`. Unlike `_rename_target()` this never
555
+ anchors at `self.parent`: a symlink target is stored as given, so
556
+ a relative one stays relative.
557
+ """
558
+ if isinstance(target, str):
559
+ return self._from_decoded_path(target)
560
+ return target
561
+
562
+ @_providers.notimplemented
563
+ def _listdir(self) -> "_ty.Iterator[str]": ...
564
+
565
+ @_providers.derived_from("_listdir")
566
+ def _scandir(self) -> "_ty.Iterator[_ty.Tuple[str, _ty.Optional[FileStat]]]":
567
+ """Default: derives from `_listdir()` + one `stat()` per child (no
568
+ round-trip savings over `iterdir()`). Schemes whose listing
569
+ call already returns metadata should override this directly instead
570
+ (see docs/guides/extending.md) -- HTTPURIPath/DAVURIPath/SFTPURIPath/FTPURIPath/
571
+ S3URIPath all do. A name that is not one path component
572
+ (`providers.is_safe_child_name()`) is skipped, and an override must skip
573
+ it too."""
574
+ for name in self._listdir():
575
+ # A listing is untrusted input: a name that is not one component
576
+ # ("..", "a/b", "") must never become a child path.
577
+ if not _providers.is_safe_child_name(name):
578
+ continue
579
+ child = self._make_child_relpath(name)
580
+ try:
581
+ # Non-following, like Path._scandir(): rm(recursive=True) and
582
+ # walk() trust this stat, and a following one reported a
583
+ # directory symlink as a real directory to descend into.
584
+ stat = FileStat.from_path(child, follow_symlink=False)
585
+ except OSError:
586
+ stat = None
587
+ yield name, stat
588
+
589
+ def _make_child_relpath(
590
+ self, name: str, stat_hint: "FileStat" = None, **kwargs
591
+ ) -> _ty.Self:
592
+ # The child takes the backend this path already holds and builds none
593
+ # (`_from_parsed_parts`): naming a child is a pure-path operation. A
594
+ # listing has one by the time it gets here, so its children share it.
595
+ inst = super()._make_child_relpath(name, **kwargs)
596
+ inst._stat_hint = stat_hint
597
+ return inst
598
+
599
+ def _pop_stat_hint(self) -> "FileStat | None":
600
+ """Consume this instance's pre-seeded stat (from `_scandir()`), if
601
+ any -- single-use: the next `stat()` call on this same object always
602
+ re-fetches, so a live mutation is never masked by a stale hint. A
603
+ type-only hint (`size_known` False) is consumed and not returned:
604
+ `stat()` has to give a size and a time, which it does not hold."""
605
+ hint = self._stat_hint
606
+ self._stat_hint = None
607
+ if hint is not None and not hint.size_known:
608
+ return None
609
+ return hint
610
+
611
+ def _st_mode(self, *, follow_symlinks=True):
612
+ # The type questions are answered by a type-only hint, without a request. The
613
+ # hint answers a non-following stat, so it speaks for a followed one only when
614
+ # the entry is not a link.
615
+ hint = self._stat_hint
616
+ if hint is not None and not hint.size_known:
617
+ self._stat_hint = None
618
+ mode = hint.st_mode
619
+ if _stat.S_IFMT(mode) and (not follow_symlinks or not _stat.S_ISLNK(mode)):
620
+ return mode
621
+ return super()._st_mode(follow_symlinks=follow_symlinks)
622
+
623
+ @_providers.derived_from("_scandir")
624
+ def iterdir(self) -> "_ty.Iterator[Self]":
625
+ for name, stat in self._scandir():
626
+ yield self._make_child_relpath(name, stat_hint=stat)
627
+
628
+
629
+ _DEFAULT_CHILD_BUILDERS.add(URIPath._make_child_relpath)