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,459 @@
1
+ from __future__ import annotations
2
+
3
+ import errno as _errno
4
+ import os as _os
5
+ import typing as _ty
6
+ import urllib.parse as _urlparse
7
+ import xml.etree.ElementTree as _ET
8
+
9
+ from pathapi import FileStat
10
+ from pathapi import providers as _providers
11
+ from pathapi.providers import check_follow
12
+ from .._uri import URI
13
+ from ..source import compose_uri
14
+ from . import _httpdate
15
+ from pathapi.http import (
16
+ drop_body,
17
+ os_error,
18
+ peer_text,
19
+ raise_for_status,
20
+ read_limited,
21
+ server_size,
22
+ split_userinfo,
23
+ translate_errors,
24
+ )
25
+ from ._http._headers import _IDENTITY_ENCODING
26
+ from ._http._path import HTTPURIPath
27
+ from ._http._streams import _EXCLUSIVE_CREATE, _UploadStream, _response_reader
28
+
29
+ _NS = {"D": "DAV:"}
30
+ _MULTISTATUS = "{DAV:}multistatus"
31
+
32
+ _PROPFIND_BODY = b"""<?xml version="1.0" encoding="utf-8"?>
33
+ <D:propfind xmlns:D="DAV:">
34
+ <D:prop>
35
+ <D:resourcetype/>
36
+ <D:getcontentlength/>
37
+ <D:getlastmodified/>
38
+ </D:prop>
39
+ </D:propfind>"""
40
+
41
+
42
+ def _found_props(elem) -> "list":
43
+ """The `<D:prop>` elements of every successful `<D:propstat>` of a
44
+ `<D:response>`. RFC 4918 groups properties into one propstat per status
45
+ (200 for found, 404 for missing) in no fixed order, so reading only the
46
+ first one could read the 404 group; a propstat with no status counts."""
47
+ props = []
48
+ for propstat in elem.findall("D:propstat", _NS):
49
+ code = _status_code(propstat.findtext("D:status", namespaces=_NS))
50
+ prop = propstat.find("D:prop", _NS)
51
+ if prop is not None and (code is None or 200 <= code < 300):
52
+ props.append(prop)
53
+ return props
54
+
55
+
56
+ def _find_prop(props, name: str):
57
+ for prop in props:
58
+ found = prop.find(name, _NS)
59
+ if found is not None:
60
+ return found
61
+ return None
62
+
63
+
64
+ def _parse_response(elem) -> "tuple[str, bool, int, str]":
65
+ href = elem.findtext("D:href", namespaces=_NS) or ""
66
+ props = _found_props(elem)
67
+ resourcetype = _find_prop(props, "D:resourcetype")
68
+ is_dir = (
69
+ resourcetype is not None and resourcetype.find("D:collection", _NS) is not None
70
+ )
71
+ size_elem = _find_prop(props, "D:getcontentlength")
72
+ # A missing, non-numeric or negative length is no size.
73
+ size = server_size(size_elem.text if size_elem is not None else None) or 0
74
+ lm_elem = _find_prop(props, "D:getlastmodified")
75
+ lm = lm_elem.text if lm_elem is not None else None
76
+ # Still percent-encoded: decoding before `urlsplit()` would read a
77
+ # literal "#" or "?" in a name as URL syntax.
78
+ return href, is_dir, size, lm
79
+
80
+
81
+ def _decoded_segments(path: str) -> "list[str]":
82
+ """The decoded segments of a percent-encoded URL path, a trailing slash
83
+ ignored. A segment that was `%2F` stays one segment. Undecodable bytes
84
+ are kept (`surrogateescape`) as the URI layer keeps them."""
85
+ return [
86
+ _urlparse.unquote(segment, errors="surrogateescape")
87
+ for segment in path.rstrip("/").split("/")
88
+ ]
89
+
90
+
91
+ def _status_code(text: "str | None") -> "int | None":
92
+ # "HTTP/1.1 423 Locked"
93
+ parts = (text or "").split()
94
+ if len(parts) >= 2 and parts[1].isdigit():
95
+ return int(parts[1])
96
+ return None
97
+
98
+
99
+ def _multistatus_failures(content: bytes) -> "list[tuple[str, int]] | None":
100
+ """`(href, status)` of every failed member of a 207 reply, or `None`
101
+ when the body is not a parseable multistatus."""
102
+ try:
103
+ root = _ET.fromstring(content)
104
+ except _ET.ParseError:
105
+ return None
106
+ failures = []
107
+ for response in root.findall("D:response", _NS):
108
+ href = _urlparse.unquote(response.findtext("D:href", namespaces=_NS) or "")
109
+ statuses = [response.findtext("D:status", namespaces=_NS)] + [
110
+ propstat.findtext("D:status", namespaces=_NS)
111
+ for propstat in response.findall("D:propstat", _NS)
112
+ ]
113
+ for text in statuses:
114
+ code = _status_code(text)
115
+ if code is not None and code >= 400:
116
+ failures.append((href, code))
117
+ break
118
+ return failures
119
+
120
+
121
+ def _raise_for_multistatus(resp, path) -> None:
122
+ """RFC 4918 9.6.1/9.9.4: DELETE and MOVE answer 207 when a member of
123
+ the collection could not be processed -- a partial failure, not a
124
+ success."""
125
+ failures = _multistatus_failures(read_limited(resp, path))
126
+ if failures == []:
127
+ return
128
+ detail = (
129
+ ", ".join(
130
+ f"{peer_text(href, 200)} (HTTP {code})" for href, code in failures[:10]
131
+ )
132
+ + (f" and {len(failures) - 10} more" if len(failures) > 10 else "")
133
+ if failures
134
+ else "unparseable 207 Multi-Status reply"
135
+ )
136
+ message = f"Partial failure for {path}: {detail}"
137
+ if failures and all(code in (401, 403, 423) for _href, code in failures):
138
+ raise PermissionError(_errno.EACCES, message)
139
+ raise OSError(_errno.EIO, message)
140
+
141
+
142
+ class _DAVWriteStream(_UploadStream):
143
+ def __init__(self, path: "DAVURIPath", exclusive: bool = False):
144
+ super().__init__(path)
145
+ # `exclusive` (mode "x"): see `HTTPWriteStream`.
146
+ self._exclusive = exclusive
147
+ self._ready = True
148
+
149
+ def _upload(self, data):
150
+ # 409: an intermediate collection is missing (RFC 4918 9.7.1).
151
+ statuses = {409: FileNotFoundError}
152
+ extra = {}
153
+ if self._exclusive:
154
+ statuses[412] = FileExistsError
155
+ extra = _EXCLUSIVE_CREATE
156
+ path = self._path
157
+ path._dav_request(
158
+ path.backend.write_method, data=data, statuses=statuses, **extra
159
+ )
160
+
161
+
162
+ class DAVURIPath(HTTPURIPath):
163
+ """`dav:`/`davs:` scheme: WebDAV (RFC 4918) over HTTP(S). Extends
164
+ `HTTPURIPath` with PROPFIND (stat/listdir -- real directory metadata,
165
+ replacing `HTTPURIPath`'s HTML-index scraping) and PUT/DELETE/MKCOL/MOVE
166
+ (full write support). Requests go to the equivalent `http:`/`https:`
167
+ URL (`_wire_uri()`); `as_uri()` still reports `dav:`/`davs:`. Reuses
168
+ `HTTPURIPath`'s `HTTPBackend` (transport + settings) -- no new dependency.
169
+
170
+ `rmdir()` enforces pathlib's "must be empty" contract with a depth-1
171
+ PROPFIND before DELETE (WebDAV `DELETE` is recursive by spec, RFC 4918,
172
+ unlike `pathlib.Path.rmdir()`). The native recursive DELETE is still
173
+ available -- and cheaper than the base class's client-side walk -- via
174
+ `rm(recursive=True)`, overridden below to issue a single request.
175
+ """
176
+
177
+ __SCHEMES = ("dav", "davs")
178
+ __slots__ = ()
179
+
180
+ def _wire_uri(self) -> str:
181
+ # Keeps the userinfo: `HTTPBackend.request` strips it from the URL it sends and
182
+ # turns it into `auth=`. Direct string assembly, not uricompose(), as this runs on
183
+ # every request and every part is this instance's own parsed state (`_compose_uri`).
184
+ scheme = "https" if self.source.scheme == "davs" else "http"
185
+ return compose_uri(
186
+ scheme,
187
+ self.source.userinfo,
188
+ self.source.host,
189
+ self.source.port,
190
+ self.path,
191
+ str(self.query) or None,
192
+ self.fragment or None,
193
+ )
194
+
195
+ def _dav_request(self, method, *, statuses=None, keep=False, **kwargs):
196
+ """Send `method` to this resource with pathlib exception types:
197
+ `statuses` maps a status code to the exception class raised for it
198
+ (called with `self`), ahead of `translate_errors`' generic
199
+ mapping; a 207 to DELETE/MOVE raises for its failed members; 423
200
+ Locked is a PermissionError. The body of the reply is dropped (see
201
+ `drop_body`) unless `keep`, when the caller reads it."""
202
+ statuses = {
203
+ 404: FileNotFoundError,
204
+ 401: PermissionError,
205
+ 403: PermissionError,
206
+ 423: PermissionError,
207
+ **(statuses or {}),
208
+ }
209
+ with translate_errors(self):
210
+ resp = self.backend.request(method, self._wire_uri(), stream=True, **kwargs)
211
+ try:
212
+ error = statuses.get(resp.status)
213
+ if error is not None:
214
+ if isinstance(error, type):
215
+ # `(errno, strerror, filename)`, as pathlib raises it.
216
+ raise os_error(error, self)
217
+ raise error(self)
218
+ if resp.status == 207 and method in ("DELETE", "MOVE"):
219
+ _raise_for_multistatus(resp, self)
220
+ raise_for_status(resp)
221
+ except BaseException:
222
+ drop_body(resp)
223
+ raise
224
+ if not keep:
225
+ drop_body(resp)
226
+ return resp
227
+
228
+ def _propfind(self, depth="0"):
229
+ return self._propfind_reply(depth)[0]
230
+
231
+ def _propfind_reply(self, depth: str) -> "tuple[_ET.Element, str]":
232
+ """The `<D:multistatus>` element of a PROPFIND reply, and the URL
233
+ that answered it (not this path's own after a redirect)."""
234
+ resp = self._dav_request(
235
+ "PROPFIND",
236
+ headers={"Depth": depth, "Content-Type": "application/xml"},
237
+ data=_PROPFIND_BODY,
238
+ keep=True,
239
+ )
240
+ body = read_limited(resp, self)
241
+ try:
242
+ root = _ET.fromstring(body)
243
+ except _ET.ParseError:
244
+ root = None
245
+ if root is None or root.tag != _MULTISTATUS:
246
+ # A non-WebDAV endpoint or proxy answering 200 with HTML or other XML: an I/O error,
247
+ # so `exists()`/`is_dir()` report False instead of leaking a SyntaxError subclass,
248
+ # and a login page is not an empty directory.
249
+ raise OSError(_errno.EIO, f"Invalid PROPFIND response for {self}")
250
+ return root, resp.url or self._wire_uri()
251
+
252
+ def stat(self, *, follow_symlinks=True, walk_up_last_modified=False):
253
+ # `walk_up_last_modified` has nothing to do: the reply carries the
254
+ # modification time.
255
+ hint = self._pop_stat_hint()
256
+ if hint is not None:
257
+ return hint
258
+ root = self._propfind(depth="0")
259
+ responses = root.findall("D:response", _NS)
260
+ if not responses:
261
+ raise FileNotFoundError(self)
262
+ _href, is_dir, size, lm = _parse_response(responses[0])
263
+ return FileStat(st_size=size, st_mtime=_httpdate.parsedate(lm), is_dir=is_dir)
264
+
265
+ def _scandir(self):
266
+ # One PROPFIND (Depth: 1) already carries type/size/mtime for every
267
+ # child -- reuse it instead of `iterdir()` + a stat per child.
268
+ root, answered = self._propfind_reply("1")
269
+ collection = _urlparse.urlsplit(answered)
270
+ # Compared segment by segment, decoded: the wire path is
271
+ # percent-encoded, so a directory named "my dir" otherwise listed
272
+ # itself as a child.
273
+ here = _decoded_segments(collection.path)
274
+ host = (collection.hostname or "").lower()
275
+ base = collection._replace(
276
+ path=collection.path.rstrip("/") + "/", query="", fragment=""
277
+ ).geturl()
278
+ selves = []
279
+ members = []
280
+ replies = 0
281
+ for elem in root.findall("D:response", _NS):
282
+ replies += 1
283
+ href, is_dir, size, lm = _parse_response(elem)
284
+ if not href.strip():
285
+ continue
286
+ try:
287
+ # Split first, decode after: a literal "#"/"?" in a name
288
+ # arrives encoded and must stay part of the name. Resolved
289
+ # against the collection, as RFC 4918 8.3 reads a reference.
290
+ where = _urlparse.urlsplit(_urlparse.urljoin(base, href.strip()))
291
+ except ValueError:
292
+ continue
293
+ if (where.hostname or "").lower() != host:
294
+ continue
295
+ segments = _decoded_segments(where.path)
296
+ if segments == here:
297
+ selves.append(is_dir)
298
+ elif segments[:-1] == here:
299
+ # A direct member only: a deeper one (a server that ignores
300
+ # the Depth) or one elsewhere is not listed here.
301
+ members.append((segments[-1], is_dir, size, lm))
302
+ if replies and not (selves or members):
303
+ raise OSError(
304
+ _errno.EIO,
305
+ f"The PROPFIND reply for {self} names neither the collection "
306
+ "nor any member of it",
307
+ )
308
+ if selves and not any(selves):
309
+ # The "." entry describing self, per RFC 4918. A Depth:1
310
+ # PROPFIND on a non-collection answers with only this entry,
311
+ # which read as an empty directory.
312
+ raise NotADirectoryError(
313
+ _errno.ENOTDIR, _os.strerror(_errno.ENOTDIR), str(self)
314
+ )
315
+ for name, is_dir, size, lm in members:
316
+ # The href is untrusted: an entry such as `%2E%2E/` decodes to
317
+ # "..", which let a recursive copy write outside its destination.
318
+ if _providers.is_safe_child_name(name):
319
+ yield name, FileStat(
320
+ st_size=size, st_mtime=_httpdate.parsedate(lm), is_dir=is_dir
321
+ )
322
+
323
+ def _listdir(self):
324
+ for name, _stat_ in self._scandir():
325
+ yield name
326
+
327
+ def _open(self, mode="r", buffering=-1):
328
+ if mode == "r":
329
+ req = self._get(self._wire_uri(), headers=_IDENTITY_ENCODING)
330
+ content_type = req.headers.get("Content-Type", "")
331
+ if content_type.split(";")[0].strip().lower() == "text/html":
332
+ # GET on a collection is server-defined (RFC 4918 9.4) and
333
+ # usually an HTML index, which was read back as content.
334
+ # Only an HTML answer pays the PROPFIND that tells them apart.
335
+ try:
336
+ is_dir = self.stat().is_dir()
337
+ except OSError:
338
+ is_dir = False
339
+ if is_dir:
340
+ req.close()
341
+ raise IsADirectoryError(
342
+ _errno.EISDIR, _os.strerror(_errno.EISDIR), str(self)
343
+ )
344
+ return _response_reader(self, req, buffering)
345
+ if mode not in ("w", "x"):
346
+ raise NotImplementedError(f"open(mode={mode!r})")
347
+ if mode == "x":
348
+ self._ensure_missing()
349
+ return _DAVWriteStream(self, exclusive=mode == "x")
350
+
351
+ def _mkdir(self, mode):
352
+ self._dav_request(
353
+ "MKCOL",
354
+ statuses={
355
+ 409: FileNotFoundError,
356
+ 405: FileExistsError,
357
+ 501: FileExistsError,
358
+ },
359
+ )
360
+
361
+ def unlink(self, missing_ok=False):
362
+ # WebDAV DELETE on a collection is recursive (RFC 4918), and pathlib's unlink()
363
+ # never removes a directory: check with a Depth:0 PROPFIND first. The recursive
364
+ # DELETE stays available as `rm(recursive=True)`.
365
+ try:
366
+ root = self._propfind(depth="0")
367
+ except FileNotFoundError:
368
+ if missing_ok:
369
+ return
370
+ raise
371
+ responses = root.findall("D:response", _NS)
372
+ if responses and _parse_response(responses[0])[1]:
373
+ raise IsADirectoryError(
374
+ _errno.EISDIR, _os.strerror(_errno.EISDIR), str(self)
375
+ )
376
+ self._delete(missing_ok=missing_ok)
377
+
378
+ def _delete(self, missing_ok=False):
379
+ # The raw DELETE, with no collection guard: `rmdir()` has already
380
+ # verified an empty directory before calling it.
381
+ try:
382
+ self._dav_request("DELETE")
383
+ except FileNotFoundError:
384
+ if not missing_ok:
385
+ raise
386
+
387
+ def rmdir(self):
388
+ # A PROPFIND Depth:1 on a non-collection returns only its own "." entry, which
389
+ # _scandir() filters, so a *file* looks like an empty directory to _listdir() and
390
+ # rmdir() would DELETE it. stat(), not is_dir(): a missing path is FileNotFoundError.
391
+ if not self.stat().is_dir():
392
+ raise NotADirectoryError(
393
+ _errno.ENOTDIR, _os.strerror(_errno.ENOTDIR), str(self)
394
+ )
395
+ for _ in self._listdir():
396
+ raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
397
+ self._delete()
398
+
399
+ def rm(
400
+ self,
401
+ /,
402
+ recursive: bool = False,
403
+ missing_ok: bool = False,
404
+ ignore_error: "bool | _ty.Callable[[Exception, DAVURIPath], bool]" = False,
405
+ *,
406
+ follow_symlinks=False,
407
+ follow_binds=False,
408
+ ):
409
+ # A collection holds no symlinks or bindings: the policies are
410
+ # checked and have nothing to decide.
411
+ check_follow("follow_symlinks", follow_symlinks)
412
+ check_follow("follow_binds", follow_binds)
413
+ if not recursive:
414
+ return super().rm(
415
+ recursive=recursive,
416
+ missing_ok=missing_ok,
417
+ ignore_error=ignore_error,
418
+ follow_symlinks=follow_symlinks,
419
+ follow_binds=follow_binds,
420
+ )
421
+ # WebDAV DELETE is recursive by spec (RFC 4918): one request here
422
+ # replaces the base implementation's client-side stat+walk+unlink.
423
+ try:
424
+ try:
425
+ self._dav_request("DELETE")
426
+ except FileNotFoundError:
427
+ if not missing_ok:
428
+ raise
429
+ except Exception as error:
430
+ onerror = (
431
+ ignore_error
432
+ if callable(ignore_error)
433
+ else (lambda _e, _p: bool(ignore_error))
434
+ )
435
+ if not onerror(error, self):
436
+ raise
437
+
438
+ def rename(self, target: "DAVURIPath | URI | str"):
439
+ target = self._rename_target(target)
440
+ # The new path carries the target's own query, not this path's: a
441
+ # signature or token in the query is good for the source only.
442
+ moved = self._from_parsed_parts(
443
+ self.source, target.path, target.query, target.fragment
444
+ )
445
+ # No userinfo in the header: the credentials already travel as `auth=` and a
446
+ # header value ends up in server and proxy logs. No fragment either: a server
447
+ # does not resolve it.
448
+ dest = split_userinfo(moved.with_fragment("")._wire_uri())[0]
449
+ self._dav_request(
450
+ "MOVE",
451
+ headers={"Destination": dest, "Overwrite": "F"},
452
+ statuses={
453
+ # 409: the destination's parent collection is missing.
454
+ 409: lambda _self: os_error(FileNotFoundError, target),
455
+ 412: lambda _self: os_error(FileExistsError, target),
456
+ },
457
+ )
458
+ # pathlib returns the new path.
459
+ return moved
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib as _contextlib
4
+ import typing as _ty
5
+
6
+ from pathapi.protocols import supports as _supports
7
+
8
+ from ._path import URIPath
9
+
10
+
11
+ def _scandir(self):
12
+ with self._delegated():
13
+ yield from self._core_path._scandir()
14
+
15
+
16
+ def _listdir(self):
17
+ for name, _stat in self._scandir():
18
+ yield name
19
+
20
+
21
+ def _forward(name: str):
22
+ """A method that calls the core path's method of the same name, with the
23
+ errors it raises naming this path."""
24
+
25
+ def method(self, *args, **kwargs):
26
+ with self._delegated():
27
+ return getattr(self._core_path, name)(*args, **kwargs)
28
+
29
+ method.__name__ = name
30
+ return method
31
+
32
+
33
+ #: Every hook a scheme may delegate, by name; `_DELEGATED` of the class says
34
+ #: which of them it does, so a hook the core path lacks stays unimplemented.
35
+ _FORWARDERS: "dict[str, object]" = {
36
+ name: _forward(name)
37
+ for name in (
38
+ "_open",
39
+ "_mkdir",
40
+ "unlink",
41
+ "rmdir",
42
+ "chmod",
43
+ "_chown",
44
+ "_utime",
45
+ "_symlink_to",
46
+ "readlink",
47
+ "_write_batch",
48
+ "_same_filesystem",
49
+ "_node_key",
50
+ )
51
+ }
52
+ _FORWARDERS["_scandir"] = _scandir
53
+ _FORWARDERS["_listdir"] = _listdir
54
+
55
+
56
+ class DelegatingURIPath(URIPath):
57
+ """A `URIPath` whose I/O is done by a core path that needs no URI.
58
+
59
+ A scheme names the hooks it hands over in `_DELEGATED` and builds the core
60
+ path for its URI in `_make_core()`; everything about URIs (parsing,
61
+ joining, backends, the text of the path) stays here. The core path is made
62
+ once per instance and used for every call, and an `OSError` it raises names
63
+ this path, not the core one."""
64
+
65
+ __slots__ = ("_core",)
66
+ _DELEGATED: "_ty.Sequence[str]" = ()
67
+ #: Whether an `OSError` of the core path is rewritten to name this path; a
68
+ #: core path that is itself the user's file (`LocalPath`) keeps its own.
69
+ _RELABEL = True
70
+
71
+ def __init_subclass__(cls, **kwargs):
72
+ super().__init_subclass__(**kwargs)
73
+ for name in cls.__dict__.get("_DELEGATED", ()):
74
+ if name not in cls.__dict__:
75
+ setattr(cls, name, _FORWARDERS[name])
76
+
77
+ def _make_core(self):
78
+ """The core path this URI names."""
79
+ raise NotImplementedError
80
+
81
+ @property
82
+ def _core_path(self):
83
+ core = self._core
84
+ if core is None:
85
+ core = self._core = self._make_core()
86
+ return core
87
+
88
+ @_contextlib.contextmanager
89
+ def _delegated(self, *extra: "tuple[str, str]"):
90
+ """Runs a call of the core path; an `OSError` that names the core path
91
+ (or a pair of `extra` `(core text, this text)`) names this one."""
92
+ if not self._RELABEL:
93
+ yield
94
+ return
95
+ try:
96
+ yield
97
+ except OSError as error:
98
+ core = str(self._core_path)
99
+ names = {**({core: str(self)} if core else {}), **dict(extra)}
100
+ _relabel(error, names)
101
+ raise
102
+
103
+ def _supports(self, protocol):
104
+ """What the core path supports, for a protocol this class has a hook for;
105
+ `None` (the class's own answer, which is no) for one it has not."""
106
+ if not _supports(type(self), protocol):
107
+ return None
108
+ return _supports(self._core_path, protocol)
109
+
110
+ def stat(self, *, follow_symlinks=True):
111
+ hint = self._pop_stat_hint()
112
+ if hint is not None and not (follow_symlinks and hint.is_symlink()):
113
+ return hint
114
+ with self._delegated():
115
+ return self._core_path.stat(follow_symlinks=follow_symlinks)
116
+
117
+
118
+ def _relabel(error: OSError, names: "dict[str, str]") -> None:
119
+ for attribute in ("filename", "filename2"):
120
+ value = getattr(error, attribute)
121
+ if value in names:
122
+ setattr(error, attribute, names[value])
123
+ if any(isinstance(arg, str) and arg in names for arg in error.args):
124
+ error.args = tuple(
125
+ names[arg] if isinstance(arg, str) and arg in names else arg
126
+ for arg in error.args
127
+ )
@@ -0,0 +1,102 @@
1
+ """Schemes that other distributions provide.
2
+
3
+ `uripath` knows their names. A path of one of these schemes builds, joins,
4
+ compares and prints like any other, and the first operation that needs the
5
+ server raises an `ImportError` that names what to install.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._path import URIPath
11
+
12
+ #: Scheme -> (the distribution that provides it, its import name, what to run
13
+ #: to get it). A name listed here is known without the distribution: a path of
14
+ #: it is built, and its I/O raises an `ImportError` naming what to install. An
15
+ #: installed distribution registers its own class (an entry point of
16
+ #: `uripath.schemes`), which is found first.
17
+ SCHEMES = {
18
+ "sftp": (
19
+ "sftppath",
20
+ "sftppath",
21
+ 'pip install "sftppath[paramiko]" (or "sftppath[asyncssh]")',
22
+ ),
23
+ "s3": (
24
+ "objpath",
25
+ "objpath",
26
+ 'pip install "objpath[s3]"',
27
+ ),
28
+ "gs": (
29
+ "objpath",
30
+ "objpath",
31
+ 'pip install "objpath[gs]"',
32
+ ),
33
+ "az": (
34
+ "objpath",
35
+ "objpath",
36
+ 'pip install "objpath[az]"',
37
+ ),
38
+ "repo": ("repopath", "repopath", "pip install repopath"),
39
+ "github": ("repopath", "repopath", "pip install repopath"),
40
+ "gitlab": ("repopath", "repopath", "pip install repopath"),
41
+ }
42
+
43
+ #: The schemes of that table whose userinfo is an access token and not a user
44
+ #: name: `str()` and `repr()` of a `Source` show none of it. Known here, and
45
+ #: not only to the distribution that provides them, so that no display form of a
46
+ #: `Source` shows a token before that distribution is loaded.
47
+ TOKEN_SCHEMES = frozenset({"repo", "github", "gitlab"})
48
+
49
+
50
+ def not_installed(scheme: str) -> ImportError:
51
+ """The `ImportError` for a scheme another distribution provides."""
52
+ distribution, package, install = SCHEMES[scheme]
53
+ return ImportError(
54
+ f"the {scheme!r} scheme is provided by the {distribution} distribution: "
55
+ f"{install}",
56
+ name=package,
57
+ )
58
+
59
+
60
+ def _unavailable(name: str):
61
+ """A hook of `URIPath` that does the one thing a path of a missing scheme
62
+ can do about I/O."""
63
+
64
+ def hook(self, *args, **kwargs):
65
+ raise not_installed(self.source.scheme.lower())
66
+
67
+ hook.__name__ = name
68
+ return hook
69
+
70
+
71
+ class ProvidedElsewhere(URIPath):
72
+ """The class of a path whose scheme another distribution provides and that is
73
+ not installed. It registers no scheme: an installed distribution registers
74
+ its own class, which is found first. Every primitive of `URIPath` raises
75
+ the `ImportError` of `not_installed()`, so each operation derived from one
76
+ does too."""
77
+
78
+ __slots__ = ()
79
+ #: The keyword arguments of the real class are that distribution's to name.
80
+ _OPTIONS = None
81
+
82
+ def _initbackend(self):
83
+ raise not_installed(self.source.scheme.lower())
84
+
85
+
86
+ for _name in (
87
+ "stat",
88
+ "_open",
89
+ "_listdir",
90
+ "_scandir",
91
+ "unlink",
92
+ "_mkdir",
93
+ "rmdir",
94
+ "rename",
95
+ "chmod",
96
+ "_chown",
97
+ "_utime",
98
+ "readlink",
99
+ "_symlink_to",
100
+ ):
101
+ setattr(ProvidedElsewhere, _name, _unavailable(_name))
102
+ del _name