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/AGENTS.md ADDED
@@ -0,0 +1,1094 @@
1
+ # `uripath` — public API header
2
+
3
+ Header-file-style reference for the `uripath` package: every public export with
4
+ its signature, arguments, contract and gotchas, so the package can be used
5
+ without reading its source. It ships inside the package and is
6
+ self-contained. Development documentation lives with the source at
7
+ <https://github.com/jose-pr/uripath>.
8
+
9
+ Names are imported from the module named in the heading of the section that
10
+ documents them. The root `uripath` exports `URI`, `URIPath`, `URILike`,
11
+ `Source`, `Query`, `QueryLike`, the functions `as_path` and `copy_stream`, the
12
+ errors `URIPathError`, `URISyntaxError` and `UnsupportedSchemeError`, and
13
+ `__version__`; the other public modules are
14
+ `uripath.source`, `uripath.query`, `uripath.exceptions` and `uripath.schemes`
15
+ (the contract a scheme implements, the registry, and the names every scheme
16
+ exports). The HTTP transport that `http:` and `dav:` send through is
17
+ `pathapi.http`, documented in the `pathapi` header. A module or name
18
+ that starts with an underscore is private and nothing there is contract: every
19
+ scheme lives in a private module.
20
+
21
+ `pip install pathapi-uri`. Required dependencies: `pathapi>=0.1.0,<0.2` and
22
+ `netimps>=0.4.0,<0.5`; parsing and percent-coding use only the standard library
23
+ (`urllib.parse`), and `netimps` is imported where it is used. The one extra, `cli`
24
+ (`duho>=0.7.3,<0.8`), adds the `uripath` command ("Command line"); every scheme
25
+ in this package runs on the standard library and on `pathapi`. `sftp:`,
26
+ `s3:`, `gs:` and `az:` are provided by other distributions ("Provided by other
27
+ distributions"), and so is the `requests` HTTP transport.
28
+
29
+ `uripath.__version__` is the version of the installed distribution, read from
30
+ its metadata. The rendered documentation is at
31
+ <https://jose-pr.github.io/uripath/> and the project overview is the
32
+ `README.md` shipped next to this file (`uripath/README.md`).
33
+
34
+ ## URIs (`uripath`)
35
+
36
+ The URI layer builds on `Path` and `Pathname` of `pathapi`: the method
37
+ surface of `Path` (`read_text()`, `iterdir()`, `glob()`, `walk()`, `copy()`,
38
+ `move()`, `rm()`, ...) is that of the `pathapi` header. The classes are
39
+ `pathlib`-shaped: their method names, signatures and exception types are those
40
+ of `pathlib.Path`, and every deliberate difference is listed, with its
41
+ rationale, at <https://jose-pr.github.io/uripath/divergences/>.
42
+
43
+ ### `URI`
44
+
45
+ ```python
46
+ class URI(Pathname):
47
+ def __init__(self, *uris, **options): ...
48
+ source: Source
49
+ path: str
50
+ query: Query
51
+ fragment: str
52
+ stem
53
+ suffix
54
+ parts
55
+ normalized_path
56
+ segments
57
+ parent
58
+ def as_uri(self, sanitize=False): ...
59
+ def with_source(self, source): ...
60
+ def with_path(self, path): ...
61
+ def with_segments(self, *segments): ...
62
+ def with_query(self, query): ...
63
+ def with_fragment(self, fragment): ...
64
+ def with_name(self, name) -> Self: ...
65
+ def with_suffix(self, suffix) -> Self: ...
66
+ def with_stem(self, stem) -> Self: ...
67
+ def is_absolute(self): ...
68
+ def is_relative_to(self, other): ...
69
+ def relative_to(self, other, *, walk_up=False): ...
70
+ def is_local(self): ...
71
+ def as_posix(self): ...
72
+ def __fspath__(self): ...
73
+ def host_fspath(self) -> str: ...
74
+ ```
75
+
76
+ Pure RFC 3986 URI, parsed lazily. Arguments
77
+ (`str`, `bytes`, `URI`, `pathlib`/`pathapi` paths, `os.PathLike`;
78
+ `None` and `""` are the empty URI, anything else is a `TypeError` naming its
79
+ type) join right to left like `joinpath` (an absolute one restarts); this is not RFC
80
+ 3986 reference resolution (`URI("http://h/d") / "x"` is `http://h/d/x`),
81
+ but the dot segments of the joined path are removed as RFC 3986 5.2.4
82
+ says: `URI("http://h/d/", "../x")`, `URI("http://h/d/") / "../x"`,
83
+ `URI("http://h/d/") / URI("../x")` and `URI("http://h/d/../x")` are all
84
+ `http://h/x`, and `URI("http://h/d/x") / ".."` is `http://h/d/`. A `..`
85
+ that would pass the root of an absolute path is dropped; a relative path
86
+ keeps its leading `..` (`URI("a") / "../../x"` is `../x`). A
87
+ percent-encoded dot segment (`%2e%2e`) is removed after decoding. An
88
+ absolute local path **object** (`pathlib.Path`, `LocalPath`) becomes
89
+ `file:`; a relative one joins like a `PurePath`. A `str` argument is always
90
+ URI text: wrap a path string (`LocalPath(s)`), because `URIPath("/abs/x")`
91
+ is a path with no scheme whose I/O raises `NotImplementedError` and
92
+ `URIPath("C:/Temp/x")` has the scheme `c`. `URI` takes no keyword argument
93
+ (`**options` is for the subclasses: see `_OPTIONS`).
94
+
95
+ - Properties: `source -> Source`, `path -> str` (percent-decoded),
96
+ `query -> Query` (**percent-encoded as received**, sent unchanged; empty
97
+ when there is none; not a `str`: compare `str(uri.query)`), `fragment -> str` (`""` when there is none),
98
+ `parts -> (source, path, query, fragment)` (not path
99
+ segments; use `segments`), `normalized_path`, `segments`, `parent`
100
+ (`http://h/a` → `http://h/`; a trailing `/` is kept, so
101
+ `URI("http://h/d/").name == ""`).
102
+ - Methods: `as_uri(sanitize=False)`, `with_source()`, `with_path()`,
103
+ `with_segments()`, `with_query(QueryLike | None)` (a `str` is taken as
104
+ already encoded; `None` removes the query), `with_fragment()`. `with_path(path)` gives a relative path under an
105
+ authority the leading `/` the constructor gives it; `with_segments(*segments)`
106
+ joins the spelling `segments` returns (a leading `""` is the root) with `/`,
107
+ and an element may be `str`, `bytes`, a `PurePath` or an `os.PathLike`.
108
+ `is_absolute()` (path starts with `/`),
109
+ `is_relative_to(other)`, `relative_to(other, *, walk_up=False)`
110
+ (`http://h` counts as the root), `is_local()`
111
+ (`Source.is_local()`), `as_posix()` (`user@host:path` when a host is
112
+ present).
113
+ - Query and fragment: every derivation that changes only the path keeps the
114
+ query and the fragment of the path it starts from, so
115
+ `p.parent / p.name == p`. That is `/` and `joinpath()` with a `str`
116
+ (whatever the key: nested, dotted, absolute, with empty segments),
117
+ `parent`, `parents`, `with_name()`, `with_stem()`, `with_suffix()`,
118
+ `with_segments()`, `with_path()`, `relative_to()`, and the children that
119
+ `iterdir()`, `glob()`, `rglob()` and `walk()` yield. A `URI` argument is
120
+ joined as the constructor joins it: the last segment that sets a query (or a
121
+ fragment) decides. The exception is a listing whose entries are named by
122
+ the server: a child of an `http:` or `dav:` directory is its `href` resolved
123
+ against the directory (RFC 3986 5.2), so the query that shapes an index
124
+ (`?sort=name`) and the fragment belong to the request for the listing and do
125
+ not reach the entries; `directory / "f.txt"` keeps them. A `str`
126
+ destination of `rename()`, `copy()` and `move()` is another resource and
127
+ takes neither (a signature in the query signs this resource only). To drop
128
+ them from any path, `with_query(None)` and `with_fragment("")`.
129
+ - `stem` and `suffix` are those of `name` (the last segment of the decoded
130
+ path; the query and the fragment are not part of it). `URILike` =
131
+ `str | URI | os.PathLike`, what the constructor and the join accept.
132
+ - `str()`/`repr()` drop the password (`ftp://u:pw@h/p` → `ftp://u@h/p`);
133
+ `as_uri(sanitize=False)` keeps it. Non-ASCII hosts render as IDNA. A
134
+ query or fragment is printed as it is: a token carried there shows in
135
+ `str()`/`repr()`.
136
+ - Authority: the first unescaped `:` of the userinfo separates user from
137
+ password (`us%3Aer:pw` is the user `us:er`); a host of digits only
138
+ (`http://20240101/key`) is the host; a port is 0-65535 (`URISyntaxError`
139
+ otherwise, as for a `:` followed by anything but digits); an IPv6 zone is
140
+ written `[fe80::1%25eth0]` (a bare `%` is read too). A name that holds a
141
+ lone surrogate (a Windows file name can) renders as its UTF-8 bytes, so
142
+ `str()`, `hash()` and `==` work on it.
143
+ - A host keeps the spelling it was given: `source.host` and the text of the URI
144
+ are `nasA` for `wss://root:pw@nasA:8443`, and only the scheme is
145
+ lower-cased by the parser (an address is its own spelling: `[FE80::1]` is
146
+ written `[fe80::1]`). Everything that compares hosts folds the case itself:
147
+ `==`, `hash()` and ordering of URIs, `same_authority()`, `is_relative_to()`
148
+ and `relative_to()`, backend sharing, `rename()`, the same-file decisions
149
+ of `copy()` and `move()`, the origin that keeps `Authorization` through a
150
+ redirect, the proxy rules, `is_local()` and the connection cache of `ftp:`.
151
+ The path, the userinfo, the query and the fragment compare exactly.
152
+ - `==` compares the URI text (with the host name in lower case). The other operand is a `URI` or a `str`, which is
153
+ parsed first and so compared by what it names, not by its spelling
154
+ (`URI("http://h/a b") == "http://h/a%20b"`); text that is not a URI is not
155
+ equal, and nothing else is (a non-URI `Pathname` included). `hash` is that of
156
+ the URI text, so a `str` that is not already in canonical form is equal to
157
+ the `URI` but hashes differently: key a `set` or `dict` by `URI`.
158
+ - An authority is written back as it was read. An empty one (`file:///x`, host
159
+ `""`) and none (`file:/x`, host `None`) are different URIs with different
160
+ text, and name the same endpoint for backend sharing.
161
+ - `__fspath__()` — the path for a `file:` URI on this machine (a named host
162
+ on Windows is a UNC path) or for a scheme with `_host_filesystem_path =
163
+ True` (a scheme whose path is meaningful on **its** host); otherwise
164
+ `NotImplementedError`. `host_fspath()` — the latter only, never local.
165
+
166
+ ### `URIPath`
167
+
168
+ ```python
169
+ class URIPath(URI, Path):
170
+ def __init__(
171
+ self, *args, schemesmap=None, findclass=False, **kwargs,
172
+ ): ...
173
+ @classmethod
174
+ def __init_subclass__(cls, replace=False, **kwargs): ...
175
+ @classmethod
176
+ def schemes(cls) -> tuple[str, ...]: ...
177
+ @classmethod
178
+ def scheme_class(cls, name) -> type[URIPath]: ...
179
+ backend
180
+ def with_backend(self, backend): ...
181
+ def _initbackend(self): ...
182
+ def _listdir(self) -> Iterator[str]: ...
183
+ def _scandir(self) -> Iterator[Tuple[str, Optional[FileStat]]]: ...
184
+ def _reduce_options(self) -> dict: ...
185
+ ```
186
+
187
+ `URI` + `Path`. The bare class (or `findclass=True`)
188
+ returns the subclass registered for the scheme, or plain `URIPath` for an
189
+ unknown scheme (its I/O raises `NotImplementedError`). The keywords are
190
+ `schemesmap`, `findclass`, `backend=` (see `backend` below) and the ones the
191
+ scheme class names in `_OPTIONS`; any other is a `TypeError` naming it, so a
192
+ misspelt `backend=` is never read as "use the default". The class of a scheme
193
+ that another distribution implements and that is not installed takes any
194
+ keyword: they are that distribution's to name. Resolution: classes
195
+ already imported → built-in `uripath.schemes.*` module → entry point
196
+ in group `uripath.schemes`, and an entry point never takes a built-in
197
+ name. An explicit `schemesmap` is the only map consulted. It is an allow-list of classes for dispatch, not a sandbox:
198
+ the path keeps it and every class chosen after construction uses it -- a
199
+ join with a `URI`/`URIPath` argument, `with_source()`, a `copy()`/`move()`
200
+ `str` destination and the outer URI of a `zip:`/`tar:`/`archive:` path (a
201
+ scheme the map omits gives a plain `URIPath`, whose I/O raises
202
+ `NotImplementedError`) -- but it restricts nothing an already-built path or
203
+ class does.
204
+
205
+ - Registering: `__SCHEMES = ("myscheme",)` in the class body (name-mangled:
206
+ redeclare per class, a subclass that lists none registers none; the class
207
+ name must not start with `_`). Defining or importing the subclass is
208
+ enough, including after the first dispatch, and it is the one mechanism: a
209
+ distribution that ships schemes declares them as entry points of the
210
+ group `uripath.schemes` (`myscheme = "pkg.module:MyPath"`) and the
211
+ class registers itself when the entry point is loaded. A name another
212
+ class holds, or a built-in name (whether or not its module is imported
213
+ yet), raises `ValueError` at the class statement, naming the holder and
214
+ registering none of the class's names; `class MyPath(URIPath,
215
+ replace=True)` takes the names it lists from whatever held them. The same
216
+ class statement run again (same `__module__` and `__qualname__`, as
217
+ `importlib.reload()` or a function called twice does) is the same class:
218
+ the newer one takes its names without `replace=True`. A built-in class
219
+ defined after a replacement leaves the replacement in place.
220
+ - `schemes() -> tuple[str, ...]` — the registered names, sorted: every
221
+ class defined so far, every built-in scheme and every entry point of the
222
+ group, whether or not its module is imported yet, and the schemes another
223
+ distribution provides (`sftp`, `s3`, `gs`, `az`).
224
+ `scheme_class(name)` — the class for `name` (case-insensitive), importing
225
+ its module or loading its entry point first; `UnsupportedSchemeError` naming
226
+ the registered schemes when there is none, and the `ImportError` of a
227
+ built-in module that cannot be imported. Both are classmethods of the
228
+ one registry, whichever class they are called on.
229
+ - `backend` — per-instance connection state from `_initbackend()` (base:
230
+ `None`), built on first use. A derived path (`/`, `joinpath()`,
231
+ `parent`, `parents`, `with_name()`, `with_suffix()`, `with_query()`,
232
+ `relative_to()`, `URIPath(base, x)`, a `URI`/`PurePath` join) never
233
+ builds one -- so it never imports an I/O library either -- and takes the
234
+ backend its source path already holds, or none. Paths derived from one
235
+ another on one endpoint share one slot for the backend they derive for
236
+ themselves: the first of them to need one calls `_initbackend()` (under a
237
+ lock, so two threads build one) and the rest then use that object, so 20
238
+ children of a fresh root open one connection. A path built separately
239
+ (`URIPath(url)` twice), one on another endpoint, the sourceless result of
240
+ `relative_to()` and a path that holds a supplied backend are not in it.
241
+ `with_backend(backend)` returns a copy using `backend`. A backend is only
242
+ shared within one endpoint (scheme, userinfo, host, port): a join,
243
+ `with_source()` or `URIPath(base, url)` onto another endpoint takes none
244
+ and builds a fresh one on first use, so credentials and sessions never
245
+ follow. When several joined paths hold one, the rightmost whose endpoint
246
+ is the result's is taken; the sourceless result of `relative_to()` holds
247
+ none, so `dst_root / src.relative_to(src_root)` uses `dst_root`'s.
248
+ `_same_filesystem()`: two URIs are on different filesystems only when
249
+ BOTH carry a backend the caller supplied (`backend=`, `with_backend()`,
250
+ or inherited from such a path) and those are different objects -- two
251
+ connections, or fakes standing in for two hosts. A backend a path built
252
+ for itself counts as none, so two separately built paths to one URL are
253
+ the same file. The path records that it built the backend and passes the
254
+ record on, so this holds for any backend object, a `dict` or a slotted
255
+ class without `__weakref__` included. A scheme that hands its own backend
256
+ on through `backend=` (as a `with_source()` override does) is recognised
257
+ as derived when the object is weakly referenceable (every built-in
258
+ `Base*Backend` is) and `_initbackend()` built it.
259
+ Copies: `pickle` holds the URI text (userinfo included, since that is the
260
+ URI) and the `schemesmap`, and nothing else: no session headers, client
261
+ kwargs or live client. A supplied backend goes along only if its class
262
+ sets `picklable = True`; a derived one is built again on demand.
263
+ `copy.copy()` and `copy.deepcopy()` share the backend object, supplied or
264
+ derived, and never duplicate a connection. None of them carries the stat
265
+ hint. `==`, `hash()`, `_same_filesystem()` and `_node_key()` answer as
266
+ before, except that a pickle which dropped a supplied backend leaves a path
267
+ with none, which `_same_filesystem()` cannot tell from another host's.
268
+ A subclass with per-path state that is not a secret returns it from
269
+ `_reduce_options()` (constructor keywords).
270
+ - Listing: implement `_listdir() -> Iterator[str]` or override
271
+ `_scandir()`; `iterdir()` wraps each name with the entry's stat as a
272
+ single-use hint (the child's first `stat()` returns it, later calls
273
+ re-fetch). The default `_scandir()` skips a listed name that is not one
274
+ path component; an override must do the same.
275
+ - `/` and `joinpath()` choose the result class from the scheme.
276
+ - `rename()`/`symlink_to()` take a `str` as an already-decoded path (`?`,
277
+ `#`, `%`, `:` are filename characters); a relative `rename()` target is a
278
+ sibling of `self`. A target on another endpoint (or another archive or
279
+ Azure container) raises `NotImplementedError`, so `move()` copies and
280
+ deletes.
281
+ - `copy()`/`move()` read a `str` destination **by its shape**: with a
282
+ scheme (`ftp://host/key`, `data:,abc`) it is a URI, so a cross-scheme
283
+ copy works; without one it is a decoded path on this endpoint — absolute
284
+ replaces the path, relative is a sibling, as `rename()` resolves it — and
285
+ keeps this path's source and backend rather than opening a second
286
+ connection. A one-letter scheme is read as a drive, so `C:/Temp/x` is a
287
+ decoded path: the Windows path of that name on a `file:` path, a relative
288
+ sibling (`http://h/d/C:/Temp/x`) on any other scheme.
289
+
290
+ ### `as_path` and `copy_stream` (`uripath`)
291
+
292
+ ```python
293
+ def as_path(text: str) -> LocalPath | URIPath: ...
294
+ def copy_stream(
295
+ source: str | os.PathLike | Path | BinaryIO,
296
+ target: str | os.PathLike | Path | BinaryIO,
297
+ *,
298
+ exclusive: bool = False,
299
+ chunk_size: int = 1048576,
300
+ ) -> None: ...
301
+ ```
302
+
303
+ `as_path(text)` reads text the way the `uripath` command reads an argument.
304
+ It is a `URIPath` when it starts with an RFC 3986 scheme and either holds
305
+ `://` or the scheme is one a class registers (`data:`, `zip:`, `file:`,
306
+ `sftp:`, ...); anything else is a `LocalPath`, so `C:/data` (a one-letter
307
+ scheme is a drive), `12:30.txt`, `notes:draft` and `./a:b` stay local. A
308
+ scheme another distribution provides gives a `URIPath` whose first I/O raises
309
+ the `ImportError` that names what to install. `TypeError` for what is not a
310
+ `str`. It reads and connects to nothing.
311
+
312
+ `copy_stream(source, target)` copies bytes, `chunk_size` at a time, without
313
+ holding the object in memory. Each end is a binary stream (anything with
314
+ `read()` or `write()`), a path object, or text for `as_path()`; no text is
315
+ special, so `"-"` names a file called `-`. A path is opened and closed
316
+ inside the call; a stream stays open and is the caller's, and a target
317
+ stream is flushed at the end. `exclusive=True` opens a target path with an
318
+ exclusive create and raises `FileExistsError` for an existing one (no
319
+ check-then-write race); it means nothing for a target stream. A target
320
+ stream whose reader went away (a pipe whose other end closed; Windows reports
321
+ `OSError(EINVAL)`) raises `OutputClosedError`, which is also a
322
+ `BrokenPipeError`; the same error from a path's own connection is not
323
+ translated.
324
+
325
+ ## Sources (`uripath.source`)
326
+
327
+ ```python
328
+ class Source(NamedTuple):
329
+ def __init__(self, scheme, userinfo, host, port): ...
330
+ def reveal(self) -> str: ...
331
+ @classmethod
332
+ def parse(cls, text, *, strict=True) -> Source: ...
333
+ @classmethod
334
+ def try_parse(cls, text, default=None, *, strict=True) -> Source | None: ...
335
+ def keys(self): ...
336
+ def parsed_userinfo(self) -> tuple[str, str]: ...
337
+ def get_scheme_cls(self, schemesmap=None): ...
338
+ def is_local(self) -> bool: ...
339
+ ```
340
+
341
+ `NamedTuple`,
342
+ falsy when all fields are empty; indexes by position, slice or field name.
343
+ `host` keeps the spelling it was given (`Source.parse("http://NasA").host ==
344
+ "NasA"`), so two `Source`s compare as tuples, exactly: use `same_authority()`
345
+ to ask whether they name one endpoint.
346
+ `str(source)` is the canonical text: the composer `URI.as_uri()` uses (`file://`
347
+ for an empty host and `file:` for none; an invalid scheme or port is a
348
+ `ValueError`), without the
349
+ password; `repr()` redacts it too, and both drop the whole userinfo for
350
+ `repo:`/`github:`/`gitlab:` (the fields keep it). `reveal()` is the text with
351
+ the password, as `URI.as_uri(sanitize=False)` is for a URI; it is named as an
352
+ action because its result carries the secret. `Source.parse(text, *,
353
+ strict=True)` reads the authority as `URI` does (`URISyntaxError` for a
354
+ path/query/fragment when strict, naming the component and never the text;
355
+ `TypeError` for what is not a `str`); `Source.try_parse(text, default=None, *,
356
+ strict=True)` returns `default` for text that does not parse and still raises
357
+ `TypeError` for what is not text. `parse(str(source))` is `source` except for
358
+ the password,
359
+ `parsed_userinfo() -> (user, password)` (`""` when absent; `userinfo`
360
+ stays the decoded `user:password` text),
361
+ `get_scheme_cls(schemesmap=None) -> type[URIPath]`, `keys()` (the field
362
+ names, so `dict(source)` works), `is_local()` —
363
+ `localhost`/empty host or an address of this machine (IP literal, or any
364
+ A/AAAA answer via `netimps`); the answer depends on the host alone and is
365
+ kept for 60 seconds for at most 256 hosts, so a miss does DNS.
366
+
367
+ ## Queries (`uripath.query`)
368
+
369
+ ```python
370
+ class Query:
371
+ ENCODING = "utf-8"
372
+ SEPARATOR = "&"
373
+ def __init__(self, pairs=(), *, separator=None, encoding=None): ...
374
+ @classmethod
375
+ def parse(cls, text, *, separator="&", encoding="utf-8") -> Query: ...
376
+ separator: str
377
+ encoding: str
378
+ def to_pairs(self) -> list[tuple[str, str | None]]: ...
379
+ def to_dict(self, *, single=False): ...
380
+ ```
381
+
382
+ An immutable value: the percent-encoded text and the pairs it stands for; it
383
+ is not a `str` and has no length, items or iteration (`len(query)` and
384
+ `for pair in query` are `TypeError`), and `isinstance(query, str)` is False.
385
+
386
+ - `Query.parse(text)` — any `str` parses, and is kept byte for byte: this is
387
+ what `URI.query` holds, as received, and what is sent. `TypeError` for what
388
+ is not a `str`. `Query(text)` is a `TypeError` pointing at `parse()`.
389
+ - `Query(pairs)` — from a mapping (a sequence value repeats the name) or from
390
+ `(name, value)` pairs (`None` is a bare name), encoded so that `to_pairs()`
391
+ returns what was given: `+`, `=`, the separator, `#`, `%` and white space
392
+ are escaped (`{"sig": "ab+cd=="}` is `sig=ab%2Bcd%3D%3D`). `Query(query)`
393
+ keeps the separator and encoding of the query it wraps unless they are given.
394
+ - `str(query)` is the text; `repr(query)` is `Query.parse('a=1')`, with
395
+ `separator=` and `encoding=` when they are not the defaults; `bool(query)` is
396
+ False for empty text.
397
+ - `to_pairs()` — the decoded pairs, in order. `to_dict(*, single=False)` —
398
+ each name to the list of its values, or with `single` to its last. Decoding
399
+ follows RFC 3986, not form-urlencoding: only `%XX` escapes are decoded and a
400
+ `+` stays a plus (`urllib.parse.parse_qsl` reads form text); an escape that is
401
+ not valid in `encoding` becomes a surrogate escape (`ERRORS`) and never
402
+ raises.
403
+ - Equality and `hash()` are those of the decoded pairs, so `a=%41` equals
404
+ `a=A`, and the separator decides the pairs: `a=1;b=2` is two pairs with
405
+ `separator=";"` and one with `&`. A query is equal to nothing that is not a
406
+ query (a `str` included).
407
+ - `Query.parse(str(query), separator=query.separator, encoding=query.encoding)
408
+ == query`. Copies and pickles keep the separator and encoding.
409
+ - `QueryLike` = `str | Query | Mapping | Sequence[tuple[str, str | None]]` is
410
+ what `URI.with_query()` accepts: text (taken as already percent-encoded), a
411
+ mapping, pairs, or a `Query`; `None` removes the query.
412
+
413
+ ## Built-in schemes (`uripath.schemes`)
414
+
415
+ Every class is dispatched by `URIPath(...)`; `uripath.schemes`
416
+ re-exports `FileURIPath`, `DataURIPath`, `HTTPURIPath`, `DAVURIPath`, `FTPURIPath`,
417
+ `ZipURIPath` and `TarURIPath` lazily. Backends are passed
418
+ as `URIPath(uri, backend=...)` or `path.with_backend(...)`.
419
+
420
+ ### `FileURIPath` — `file:` (`uripath.schemes`)
421
+
422
+ ```python
423
+ class FileURIPath(URIPath):
424
+ filepath
425
+ ```
426
+
427
+ `file:///abs`, `file:/abs`, `file:rel`,
428
+ `file://localhost/C:/x`. A path built from a local path object writes the
429
+ empty authority, as `pathlib.Path.as_uri()` does (`file:///C:/Windows/win.ini`,
430
+ `file:///etc/passwd`; the characters `!$&'()*+,;=:@` stay literal in the path
431
+ where `pathlib` escapes them, and both spellings read back as the same file);
432
+ a relative one has none (`file:rel/x`). `filepath -> LocalPath`; all I/O delegates to it
433
+ (listing reuses `LocalPath`'s scandir). `rename()` accepts local targets
434
+ only and returns a `FileURIPath`. No `symlink_to()`/`readlink()`. On Windows a
435
+ drive is the anchor: `parents` ends at the drive root (`file:///C:/`) and
436
+ equals the repeated `parent`, dot segments never climb above it, and a
437
+ `str` join key or `copy()`/`move()`/`rename()` destination reads `\` as a
438
+ separator (`C:\Temp\x` is an absolute path). A path with a named host is a
439
+ UNC path there, whose anchor is the share: `file://server/share/a/b` has the
440
+ ancestors `file://server/share/a` and `file://server/share/`, and the share
441
+ root is its own parent. A `FileURIPath` argument holding a drive
442
+ (`base / URIPath("file:///D:/f")`) restarts the join, as a `str` key does.
443
+ Elsewhere a backslash is a filename character and the root of a host is the
444
+ end of the chain, and every other scheme splits on `/` only.
445
+
446
+ ### `DataURIPath` — `data:` (`uripath.schemes`)
447
+
448
+ ```python
449
+ class DataURIPath(URIPath):
450
+ mediatype: str
451
+ charset: str
452
+ ```
453
+
454
+ RFC 2397
455
+ `data:[<mediatype>][;base64],<data>`. `mediatype` property (default
456
+ `text/plain;charset=US-ASCII`). RFC 2397 has no query: an unescaped `?` and
457
+ what follows it are payload (`data:,a?b` reads `a?b`), while a `#` starts the
458
+ fragment. Read-only single file: `open("r")` only,
459
+ `stat().st_size` is the decoded size, listing → `NotADirectoryError`.
460
+ `charset` is the `charset=` the media type declares (quotes removed), `"utf-8"`
461
+ when it declares none; `open()` in text mode and `read_text()` decode with it
462
+ unless `encoding=` is given. The path is the payload, not a hierarchy: `name`,
463
+ `stem`, `suffix`, `suffixes`, `parent`, `parents`, `segments`, `with_name()`,
464
+ `with_stem()`, `with_suffix()`, `with_segments()`, `/`, `joinpath()`,
465
+ `relative_to()`, `is_relative_to()` and `match()`, and a constructor given
466
+ more than one part, raise `NotImplementedError` instead of cutting the
467
+ payload at a `/` (that would name another file). `with_path()`,
468
+ `with_query()` and `with_fragment()` replace a component as asked.
469
+
470
+ ### `HTTPURIPath` — `http:`, `https:` (`uripath.schemes`)
471
+
472
+ ```python
473
+ class HTTPURIPath(URIPath):
474
+ def with_transport(
475
+ self, transport, *, headers=None, timeout=(10, 60),
476
+ write_method="PUT", append_mode="rewrite",
477
+ ): ...
478
+ def with_session(
479
+ self, session, write_method="PUT", append_mode="rewrite",
480
+ **requests_args,
481
+ ): ...
482
+ def stat(self, *, follow_symlinks=True, walk_up_last_modified=False): ...
483
+
484
+ class HTTPBackend:
485
+ def __init__(
486
+ self, transport=None, *, headers=None, timeout=(10, 60),
487
+ write_method="PUT", append_mode="rewrite",
488
+ ): ...
489
+ def request(
490
+ self, method, uri, *, data=None, headers=None, stream=False,
491
+ allow_redirects=None, timeout=...,
492
+ ): ...
493
+ def close(self): ...
494
+
495
+ class HTTPWriteStream(BytesIO):
496
+ def discard(self) -> None: ...
497
+ class HTTPAppendStream(BytesIO):
498
+ def discard(self) -> None: ...
499
+
500
+ ```
501
+
502
+ HTTP needs no library: a path sends through a **transport**, by default the
503
+ built-in `HTTPTransport` on `http.client`. The transport contract, `HTTPResponse`,
504
+ `HTTPTransport` (TLS, connection reuse, the `proxies=` provider), the rules for
505
+ redirects and for `Authorization`, the cap on a reply read whole
506
+ (`pathapi.http.MAX_REPLY_BYTES`) and the way a failed request becomes an
507
+ `OSError` are those of `pathapi.http`, documented in the `pathapi`
508
+ header under "HTTP requests". This section is what the paths add.
509
+
510
+ - **Choosing the transport.** `path.with_transport(transport, headers=None,
511
+ timeout=(10, 60), write_method="PUT", append_mode="rewrite")` installs a
512
+ backend over any transport (any object with `send()` and `close()` as that
513
+ contract says; the caller closes it). `path.with_session(session,
514
+ write_method="PUT", append_mode="rewrite", **requests_args)` is the same over
515
+ a `requests.Session` (`None` makes a new one): `headers` and `timeout` in
516
+ `requests_args` are the backend's, the rest go to the `requests` transport,
517
+ which the distribution `pathapi-requests` provides. Without that
518
+ distribution the call raises `ImportError` naming it. Without either, a
519
+ path's backend builds the built-in transport on first use (`HTTPBackend.close()`
520
+ or leaving its `with` block closes a transport the backend built); the
521
+ environment variable `PATHAPI_HTTP_TRANSPORT` (`builtin`, or the name of an
522
+ installed transport) names that default for the process.
523
+ `HTTPBackend(transport=None, *, headers=None, timeout, write_method,
524
+ append_mode)` is the backend a path holds: a `pathapi.http.HTTPClient`
525
+ whose `request()` takes a path or a URL.
526
+ - **What the backend does for every transport.** It follows redirects, adds
527
+ credentials and default headers (`User-Agent: uripath/<version>`, `Accept:
528
+ */*`), applies the default timeout `(10, 60)` seconds (`timeout=None` waits
529
+ forever), and the path maps a status to a pathlib exception. `headers` given
530
+ to the backend go with every request; request-specific headers merge over them
531
+ by name (case-blind). A header value or URL with a control character (CR and
532
+ LF included) is `ValueError`, before anything is sent. A reply it needs whole
533
+ (a PROPFIND's, a git API's JSON, `request(stream=False)`'s) may be at most
534
+ `pathapi.http.MAX_REPLY_BYTES` after decoding, however small it is on the
535
+ wire: more is `OSError(EFBIG)` naming the path, and the connection is closed.
536
+ A reply whose body nobody looks at (a write's, a DELETE's, an error's) is read
537
+ up to that many bytes and dropped, so its connection can be reused when the
538
+ body was short. The content of a file (`open("r")`, a git file) is not limited.
539
+ - Redirects: the rules are `pathapi.http`'s (`GET`/`HEAD`/`OPTIONS` follow
540
+ up to 30; a redirect that changes the scheme, host or port drops
541
+ `Authorization`; every other request is sent again once after a 307/308, for
542
+ `PROPFIND` also a 301, 302 or 303, to the same origin, and is otherwise
543
+ `OSError(EIO)`). A path asks again after a redirect where it needs the final
544
+ answer, `stat()`, `exists()`, `is_dir()`, `unlink()`, `open("x")` and a
545
+ patch-mode append included, and none of those sends `Authorization` to the
546
+ other origin. Headers other than `Authorization` from
547
+ `with_transport(headers=...)` (`X-Api-Key`, a vendor token header, a cookie)
548
+ are sent to the new host as well, even to a plain `http:` one, so keep a
549
+ secret in `Authorization`, or point the path at the final URL.
550
+ - URL userinfo is sent as `Authorization: Basic` (not in the URL) unless
551
+ `headers` or the call name an `Authorization`. Percent-escapes stand for
552
+ octets, and those octets are the credential: `caf%C3%A9` is sent as UTF-8
553
+ bytes, `%FF` as the byte `FF`. The built-in transport never reads
554
+ `~/.netrc`.
555
+ - `stat()` — `HEAD`
556
+ (`GET` on 405); a redirect is followed once, at its `Location`; a final
557
+ URL whose path ends in `/` is a directory (a query or fragment does not
558
+ matter); `st_size` from `Content-Length` (absent, non-numeric or negative
559
+ = unknown, `0`), `st_mtime` from `Last-Modified` (UTC), or from the
560
+ parent's index when `walk_up_last_modified=True`.
561
+ - An index page prints its dates with no zone, and they are read as UTC:
562
+ the `st_mtime` of a listed entry can be off by the server's offset from
563
+ UTC (`stat()` of the entry uses `Last-Modified`, which names its zone).
564
+ Compare sizes, or give `PathSyncer` `quick_check=False`.
565
+ - Listing scrapes an Apache/nginx-style HTML index, asking for the
566
+ directory's URL with a trailing `/` first; `.`/`..` rows are never
567
+ children; a non-HTML response → `NotADirectoryError` (an HTML file lists
568
+ as empty). Cannot always tell a file from an index page. The body is read
569
+ like `open()` reads a file: a cut-short, stalled or undecodable one is
570
+ `OSError(EIO)`/`TimeoutError` naming the path, never a partial list, and
571
+ one over `pathapi.http.MAX_REPLY_BYTES` (8 MiB after decoding; assign a
572
+ larger number to allow more) is `OSError(EFBIG)`. A name that is not
573
+ UTF-8 (`caf%E9`) keeps its bytes (`surrogateescape`) and is requested as
574
+ listed. A listed child carries neither the query nor the fragment of the
575
+ directory's URL (the query is addressed to the listing; see "Query and
576
+ fragment" under `URI`).
577
+ - `open("r")` streams `GET` with `Accept-Encoding: identity`; the mode is
578
+ matched exactly, so `"r+"`, `"w+"` and `"a+"` raise `NotImplementedError`.
579
+ `"w"`/`"x"` (an `HTTPWriteStream`)
580
+ buffer and send `write_method` on close. `"x"` first calls `stat()`: found
581
+ → `FileExistsError`, a failure other than not-found raises with nothing
582
+ sent; the upload then carries `If-None-Match: *`, and a 412 reply →
583
+ `FileExistsError` (a server that ignores the header leaves the window
584
+ between probe and upload open). `"a"` (an `HTTPAppendStream`):
585
+ `append_mode="rewrite"` (GET + full
586
+ re-upload, not atomic) or `"patch"` (`PATCH` with `Content-Range` from the
587
+ `HEAD` size; a `HEAD` reply without `Content-Length` → `OSError(EIO)`
588
+ naming the path, nothing sent; a refusal raises `PermissionError` for
589
+ 401/403/405/501, `OSError(EIO)` for other statuses). An `open("a")` that
590
+ raises (the read of the current content, or the `HEAD` of patch mode,
591
+ failed) has uploaded nothing. A `with` block that ends in an exception (any
592
+ `BaseException`) sends nothing, in binary and in text mode: the stream is
593
+ discarded before it is closed (`discard()`, from
594
+ `pathapi.providers.DiscardOnError`), so the file keeps its content or
595
+ is not created. A stream closed with `close()`, or by a block that ended
596
+ normally, sends once; one garbage collected unclosed sends nothing and emits a
597
+ `ResourceWarning`.
598
+ - `unlink()` sends `DELETE` and refuses a directory (`IsADirectoryError`,
599
+ judged by `stat()`); `rmdir()` requires an empty directory. No `mkdir()`,
600
+ `rename()`, `chmod()`.
601
+ - Status mapping: 404/410 → `FileNotFoundError`, 401/403/405/501 →
602
+ `PermissionError`, 409 → `FileExistsError` (`FileNotFoundError` for
603
+ writes), other → `OSError(EIO)` with the status. A transport's
604
+ `TimeoutError` and `ConnectionError` are re-raised naming the path, with
605
+ no cause. Text that came from the server and goes into a message (a reason
606
+ phrase, a `Location`, a rate-limit header, the `href` of a failed `DAVURIPath`
607
+ member) is shown with control, line-separator and bidirectional characters
608
+ escaped (`\x1b` for ESC) and cut to 120 characters (200 for a URL).
609
+
610
+ ### `DAVURIPath` — `dav:`, `davs:` (`uripath.schemes`)
611
+
612
+ ```python
613
+ class DAVURIPath(HTTPURIPath):
614
+ def stat(self, *, follow_symlinks=True, walk_up_last_modified=False): ...
615
+ ```
616
+
617
+ Same backend, `with_transport()` and `with_session()`. `stat()`/
618
+ listing via `PROPFIND` (`stat()` takes `walk_up_last_modified=` and ignores
619
+ it: the reply carries the modification time). A reply that is not a
620
+ `multistatus` document, or that names neither the collection nor any
621
+ member of it, is `OSError(EIO)`; a listing keeps a member only when its
622
+ href, resolved against the collection, names the same host and a direct
623
+ child, and a `getcontentlength` that is not a plain number is unknown (`0`).
624
+ `open("r")` on a collection → `IsADirectoryError`;
625
+ `"w"`/`"x"` send `write_method` (default `PUT`) on close (`"x"` as for
626
+ `HTTPURIPath`, probing with `PROPFIND`); `"a"` unsupported, so `append_mode`
627
+ has no effect. `mkdir()` = `MKCOL`
628
+ (missing parent → `FileNotFoundError`). `unlink()` refuses a collection;
629
+ `rmdir()` checks emptiness first; `rm(recursive=True)` is one recursive
630
+ `DELETE` (failed members of a 207 raise). `rename()` = `MOVE` with
631
+ `Overwrite: F` (existing target → `FileExistsError`), no credentials in
632
+ `Destination`, which carries the target's own query (never the source's)
633
+ and no fragment. 423 → `PermissionError`. `PROPFIND`, `PUT`, `MKCOL`, `DELETE`
634
+ and `MOVE` follow the `HTTPURIPath` redirect rule above (a listing is scoped to
635
+ the URL that answered it). No `chmod()`.
636
+
637
+ ### `FTPURIPath` — `ftp:`, `ftps:` (`uripath.schemes`)
638
+
639
+ ```python
640
+ class FTPURIBackend(BaseFTPURIBackend):
641
+ timeout
642
+ ssl_context
643
+ verify
644
+ def __init__(
645
+ self, timeout=30.0, ssl_context=None, verify=True,
646
+ ) -> None: ...
647
+
648
+ class BaseFTPURIBackend:
649
+ def client(self, source, tls) -> _ftplib.FTP: ...
650
+ def close(self) -> None: ...
651
+ ```
652
+
653
+ - `FTPURIBackend` — `timeout`
654
+ bounds connect, replies and transfers (`None` = forever). `ftps:` is
655
+ explicit TLS with `PROT P`; the certificate and host name are verified
656
+ with `ssl.create_default_context()`; `ssl_context` (e.g.
657
+ `ssl.create_default_context(cafile=...)`) wins over `verify`;
658
+ `verify=False` accepts any certificate (and logs a warning on the
659
+ `uripath` logger each time one is built). Data connections reuse the TLS
660
+ session. No user in the URI → anonymous login.
661
+ - `BaseFTPURIBackend.client()` — override to supply
662
+ connections. `close()` closes every connection the paths of the backend hold,
663
+ whichever thread opened it (one a command is running on closes when the
664
+ command ends), may be called twice, and a later use connects afresh; the
665
+ backend is a context manager that calls it. One backend may be shared
666
+ between threads, each of which keeps its own connection per server. These two classes are not `pathapi.ftp.FTPBackend` and
667
+ `BaseFTPBackend`: those belong to one host, these connect to the `Source` of
668
+ whichever path asks.
669
+ - Paths without `backend=` share one default `FTPURIBackend()`. Connections
670
+ are cached per (backend, source, tls, thread) (LRU of 128, closed on
671
+ eviction, and closed when their thread ends). A connection that carried
672
+ a command within the last `pathapi.ftp.IDLE_PROBE_SECONDS` (1 second) is used as it is; one idle for longer, or not
673
+ yet used, is probed with `NOOP` and replaced when dead.
674
+ - `FTPURIPath` is a `URIPath` over `pathapi.ftp.FTPPath`: the listing,
675
+ stat, reads, writes, `rename()`, `chmod()` and `_utime()` are those
676
+ described under "What a path does" in the FTP section of the `pathapi`
677
+ header, and an `OSError` names the URI. A `with` block over `open("w")` that raised uploads nothing.
678
+ Its URI-only parts are the endpoint (the `Source` and the
679
+ scheme's `tls`) and the backend rules above.
680
+ - An empty path (`ftp://host`) is the root, as `ftp://host/` is: `/` is
681
+ sent in every command that takes a path, never an empty argument (which a
682
+ server reads as its working directory).
683
+
684
+ ### Archives — `zip:`, `tar:`, `archive:` (`uripath.schemes`)
685
+
686
+ ```python
687
+ MEMBER_SPOOL_BYTES = 16777216
688
+
689
+ class ArchiveURIPath(URIPath):
690
+ def refresh(self) -> None: ...
691
+ def batch(self) -> ContextManager[None]: ...
692
+ def readlink(self) -> LinkTarget: ...
693
+ class ZipURIPath(ArchiveURIPath): ...
694
+ class TarURIPath(ArchiveURIPath): ...
695
+ ArchiveZipURIPath = ZipURIPath
696
+ ArchiveTarURIPath = TarURIPath
697
+ ```
698
+
699
+ `ZipURIPath` (`zip:`), `TarURIPath` (`tar:`),
700
+ `ArchiveURIPath` (`archive:`, detects the format from the outer name
701
+ `.zip`/`.jar` vs `.tar`/`.tgz`/`.tar.*`, else a `PK` magic sniff),
702
+ `ArchiveZipURIPath` (`archive+zip:`), `ArchiveTarURIPath` (`archive+tar:`). The last
703
+ two are second scheme names of `ZipURIPath` and `TarURIPath`, and the class names
704
+ are aliases of those (`ArchiveZipURIPath is ZipURIPath`); a path prints and pickles
705
+ with the scheme it was written with.
706
+
707
+ - `archive:` settles the format on first use, not when the path is built:
708
+ constructing, printing, joining, copying and pickling a path read nothing,
709
+ a non-local outer that had to be read to decide is not fetched a second
710
+ time, and a failure to read the outer propagates and decides nothing, so
711
+ the next call tries again.
712
+ - Syntax `<scheme>:<archive-uri>!/<member>`; `<archive-uri>` must carry a
713
+ scheme (`URISyntaxError` otherwise) and may be any URI, including another
714
+ archive (each leading archive scheme consumes one `!/`; nested archives
715
+ are read-only). A member name containing `!/` is written `%21/`.
716
+ `name`/`parent`/`glob()` work on the member path; `as_uri()`
717
+ percent-encodes it, and a name that is not valid UTF-8 (a tar member
718
+ written in another charset, held as surrogate escapes) keeps its bytes as
719
+ `%XX` (`caf%E9.txt`), so such a path prints, hashes, compares and parses
720
+ back to the same member.
721
+ - One shared handle per archive (keyed by the real local path, or the outer
722
+ URI), released when no path references it. `ArchiveURIPath.refresh()` forgets
723
+ what is held of the outer, so the next use reads it again; the rest is
724
+ described under `ArchivePath` in the `pathapi` header.
725
+ - `ArchiveURIPath` is a `URIPath` over `pathapi.archive.ArchivePath` (`ZipURIPath`
726
+ over `ZipPath`, `TarURIPath` over `TarPath`) on the shared handle of its outer:
727
+ member names, the archive root, links, cost, writes, `batch()`, the removal
728
+ and the damage errors are those described under "Archives
729
+ (`pathapi.archive`)" in the `pathapi` header, with an `OSError`
730
+ naming the URI. A `with` block
731
+ over `open("w")` that raised adds no member and creates no archive.
732
+ `uripath.schemes.MEMBER_SPOOL_BYTES` is the same setting as
733
+ `pathapi.archive.MEMBER_SPOOL_BYTES`.
734
+
735
+ ## For scheme implementers (`uripath`, `uripath.schemes`, `uripath.source`)
736
+
737
+ The names a `URIPath` subclass overrides or calls beyond `URI` and `URIPath`
738
+ above, in one place: the built-in schemes use all of them, so they are
739
+ contract (the hooks of `pathapi.Path` they also use are listed under "For
740
+ path implementers" in the `pathapi` header). Each helper also keeps an
741
+ underscore spelling that is the same object (`_same_authority`,
742
+ `_DerivedBackend`, `_compose_uri`, `_compose_host`, `_encode`, `_is_drive`,
743
+ `_SAFE_PATH`, `_ERRORS`).
744
+
745
+ ```python
746
+ class URI(Pathname):
747
+ def _init(self, source, path, query, fragment, **kwargs): ...
748
+ def _from_parsed_parts(self, source, path, query, fragment, /, **kwargs): ...
749
+ @classmethod
750
+ def _format_parsed_parts(
751
+ cls, source, path, query, fragment, /, sanitize=True,
752
+ ) -> str: ...
753
+ @classmethod
754
+ def _parse_uri(cls, uri) -> tuple[Source, str, Query, str]: ...
755
+ def _load_parts(self): ...
756
+ def _make_child_relpath(self, name, **kwargs) -> Self: ...
757
+ def _join_decoded(self, key) -> URI: ...
758
+ def _is_absolute_decoded(self, path) -> bool: ...
759
+ def _rename_target(self, target) -> URI: ...
760
+ def _same_location(self, other) -> bool: ...
761
+ def _identity(self) -> str: ...
762
+ _initiated
763
+ _raw_uris
764
+ _source
765
+ _query
766
+ _OPTIONS
767
+
768
+ class URIPath(URI, Path):
769
+ def _init(self, source, path, query, fragment, /, backend=None, **kwargs): ...
770
+ def _make_child_relpath(self, name, stat_hint=None, **kwargs) -> Self: ...
771
+ def _pop_stat_hint(self) -> FileStat | None: ...
772
+ def _listdir(self) -> Iterator[str]: ...
773
+ def _backend_for(self, source): ...
774
+ def _inherit_backend(self, source) -> None: ...
775
+ def _supplied_backend(self): ...
776
+ @classmethod
777
+ def _schemes(cls) -> Sequence[str]: ...
778
+ _backend
779
+ _backend_derived
780
+ _backend_candidates
781
+ _derived_cell
782
+ _schemes_in_use
783
+ _stat_hint
784
+
785
+ class DelegatingURIPath(URIPath):
786
+ _DELEGATED: Sequence[str]
787
+ _RELABEL: bool
788
+ def _make_core(self) -> Path: ...
789
+ def _delegated(self, *extra) -> ContextManager[None]: ...
790
+ def _supports(self, protocol) -> bool | None: ...
791
+ _core_path
792
+
793
+ def same_authority(a, b) -> bool: ...
794
+ class DerivedBackend: ...
795
+ ```
796
+
797
+ ```python
798
+ # uripath.source
799
+ def compose_uri(scheme, userinfo, host, port, path, query, fragment) -> str: ...
800
+ def compose_host(host) -> str: ...
801
+ def fold_host(host): ...
802
+ def percent_encode(text, safe) -> str: ...
803
+ def is_drive(segment) -> bool: ...
804
+ SAFE_PATH = "!$&'()*+,;=:@/"
805
+ ERRORS = "surrogateescape"
806
+ ```
807
+
808
+ Building and deriving a path:
809
+
810
+ - `_init(source, path, query, fragment, **kwargs)` — stores the decoded
811
+ components; every constructor, join and `with_*()` ends here, and it may run
812
+ again on a live instance. An override sets its own state, then calls
813
+ `super()._init()`. `URIPath._init` also takes `backend=`, a backend the
814
+ caller supplied.
815
+ - `_from_parsed_parts(source, path, query, fragment, /, **kwargs)` — a
816
+ same-type instance from decoded components with no parsing (a `rename()`
817
+ result, a probe); the keywords reach `_init()`.
818
+ - `_format_parsed_parts(source, path, query, fragment, /, sanitize=True)`
819
+ — the URI text of decoded components (`sanitize` drops the password);
820
+ `as_uri()` and `str()` call it, so a scheme with a different spelling
821
+ overrides it. `_parse_uri(uri)` is its reverse for URI text: `(Source,
822
+ decoded path, Query, fragment)`.
823
+ - `_OPTIONS` — the names of the keyword arguments the constructor takes: a
824
+ `frozenset`, `{"backend", "schemesmap", "findclass"}` on `URIPath`, empty on
825
+ `URI`, and `None` for any. A scheme whose class reads keywords of its own
826
+ (`ssh_config=`, say) extends it (`_OPTIONS = URIPath._OPTIONS | {"ssh_config"}`)
827
+ and returns them from `_reduce_options()`; any name not listed is a
828
+ `TypeError` at construction, before the path is built.
829
+ - `_load_parts()` — parses a path that was built from arguments and is not
830
+ parsed yet; `source`, `path`, `query` and `fragment` call it. Call it
831
+ before reading `_source`, `_query` or the backend state of a path that may
832
+ still be waiting (`_initiated` is False; `_raw_uris` holds its arguments).
833
+ - `_make_child_relpath(name, stat_hint=None, **kwargs)` — the child called
834
+ exactly `name` (one segment, never re-parsed), with the query and fragment
835
+ of this path; `iterdir()`, `_scandir()` and the `str` join build children
836
+ with it, so a scheme that gives a name a meaning (a `-`, say) overrides
837
+ it. `stat_hint` is the listing's stat for that child; `http:` and `dav:`
838
+ build a listed child without the query and fragment (see "Query and
839
+ fragment").
840
+ - `_join_decoded(key)` — joins an already-decoded path string name by name
841
+ through `_make_child_relpath()`, dot segments resolved as RFC 3986 5.2.4
842
+ says; an absolute key restarts at the root of the same source.
843
+ `_is_absolute_decoded(path)` says whether a string restarts a join (a leading
844
+ `/`; `FileURIPath` also a drive path).
845
+ - `_identity()` — the text `==`, `hash()` and ordering compare: `as_uri()` with
846
+ the host name in lower case. Key a cache on it, not on `as_uri()`, when two
847
+ spellings of a host must share an entry.
848
+ - `_rename_target(target)` — a `rename()` destination as a `URI`: a `str` is a
849
+ decoded path, resolved against `self.parent`. Raises `NotImplementedError`
850
+ when `_same_location()` says a native rename cannot reach it, which makes
851
+ `move()` copy. `_same_location(other)` is True for the same endpoint
852
+ (`same_authority()`) or a sourceless path; override it where the namespace
853
+ is narrower than the authority (an archive, an Azure container).
854
+ - `_pop_stat_hint()` — the stat `_scandir()` attached to this child, once: a
855
+ `stat()` that returns it saves the request, and the next call asks again.
856
+ A type-only hint (`size_known` False) is consumed and not returned, so
857
+ `stat()` asks the server; `is_dir()`, `is_file()`, `exists()` and
858
+ `is_symlink()` of the child use it instead (`_st_mode()`), once. A scheme
859
+ whose listing knows a file's type and not its size yields
860
+ `FileStat(is_dir=False, size_known=False)` rather than `None`: `walk()`,
861
+ `glob()` and `rm(recursive=True)` then cost one request per directory.
862
+ `http:` (an index without exact sizes) does.
863
+ - `_listdir()` — the names `_scandir()` lists by default; a stub. `iterdir()` and
864
+ the default `_scandir()` are built on it (`providers.derived_from`), so
865
+ `pathapi.protocols.supports(cls, Listing)` is true for a scheme that defines
866
+ `_listdir()` or its own `_scandir()` and false for one that does neither
867
+ (`data:`).
868
+ - `_schemes()` — the scheme names this class registers.
869
+
870
+ A stream that commits when it is closed (an upload, a rewrite of an archive)
871
+ inherits `pathapi.providers.DiscardOnError`, first in its bases, and defines
872
+ `discard()`: a `with` block over the file that raised, or the collector finalizing
873
+ it unclosed, then commits nothing (the contract is in the `pathapi` header, "For
874
+ path implementers"). The
875
+ `HTTPWriteStream` and `HTTPAppendStream` of `http:` and `dav:` are such streams;
876
+ a stream that writes through as it goes needs none.
877
+
878
+ Backend state (read it only in an override that changes how a path carries a
879
+ backend, as the archives do):
880
+
881
+ - `_backend` is the backend object or `None`; `_backend_derived` says the path
882
+ built it or inherited one that was built, not supplied;
883
+ `_supplied_backend()` returns only a supplied one and builds nothing.
884
+ - `_backend_for(source)` — `(backend, derived)` this path hands a path at
885
+ `source`: its own when `source` is its endpoint, else `(None, None)`.
886
+ `_inherit_backend(source)` settles, once the endpoint is known, which
887
+ joined segment's backend the path takes; call it first in an `_init()`
888
+ override that reads `_backend`.
889
+ - `_backend_candidates` holds the join segments (with their backends) until
890
+ then; `_derived_cell` is the slot shared by paths derived from one another
891
+ on one endpoint (`None` for one that holds a backend or was not derived);
892
+ `_schemes_in_use` is the `schemesmap=` the path was built with; `_stat_hint`
893
+ is the pending hint.
894
+ - `same_authority(a, b)` — whether two `Source`s name one endpoint (scheme,
895
+ userinfo, host, port; `""` and `None` alike): the test for letting a
896
+ backend, a connection or a rename pass from one path to another.
897
+ - `DerivedBackend` — a base class for a backend that only `_initbackend()`
898
+ creates and that cannot be weakly referenced (a tuple): without it, or
899
+ `"__weakref__"` in `__slots__`, `_same_filesystem()` reads it as supplied.
900
+
901
+ Delegating to a path that needs no URI (`uripath.schemes.DelegatingURIPath`,
902
+ the base of `file:`, `ftp:` and the archive schemes, and of `sftp:` in the
903
+ distribution that provides it):
904
+
905
+ - `_make_core()` — the `pathapi.Path` this URI names, built from `source`
906
+ and `path` and performing no I/O; it is made once per instance and is the
907
+ `_core_path`. A scheme names the hooks it hands to it in `_DELEGATED`: any of
908
+ `_scandir`, `_listdir`, `_open`, `_mkdir`, `unlink`, `rmdir`, `chmod`,
909
+ `_chown`, `_utime`, `_symlink_to`, `readlink`, `_write_batch`,
910
+ `_same_filesystem` and `_node_key`; each calls the core path's method of the
911
+ same name, and a hook not named stays what `URIPath` defines. `stat()` takes
912
+ the listing's hint (see `_pop_stat_hint()`) before it asks the core path.
913
+ - `_supports(protocol)` — what `protocols.supports()` asks an instance: the
914
+ core path's answer for a protocol this class has a hook for, so a scheme that
915
+ hands over `_utime` to a core path that cannot set times says no; `None` for a
916
+ protocol the class does not implement at all.
917
+ - `_delegated(*extra)` — a context manager for the call of a core path that a
918
+ scheme writes by hand (`rename()`, `copy()`): an `OSError` that names the core
919
+ path, or a pair of `extra` `(core text, this text)`, names this path instead.
920
+ `_RELABEL = False` turns that off for a core path that is itself the user's
921
+ file (`file:`).
922
+ - The URI layer keeps everything about URIs: parsing, joining, the backend and
923
+ how paths of one endpoint share it, the text of the path and the keywords of
924
+ the constructor (`_OPTIONS`). The protocol, its errors and its caches belong to
925
+ the core path.
926
+
927
+ Composing text:
928
+
929
+ - `compose_uri(scheme, userinfo, host, port, path, query, fragment)` — URI text
930
+ from decoded components, each percent-encoded for its part; `None` omits one
931
+ and an invalid scheme, port or path/authority pairing is a `ValueError`.
932
+ `compose_host(host)` encodes a host (an IPv6 address is bracketed, an
933
+ internationalized name is IDNA) and keeps the case of a name.
934
+ `fold_host(host)` is the host as a comparison or a key reads it: a name in
935
+ lower case, an address or `None` as it is. A scheme that keeps something per
936
+ server (a connection, a cache) keys it on `fold_host(source.host)`, or on
937
+ `same_authority()` of two sources. `percent_encode(text, safe)` encodes UTF-8
938
+ and keeps the characters of `safe`; `SAFE_PATH` is the set a path keeps.
939
+ `ERRORS` is the error handler for percent-decoding and re-encoding
940
+ (`"surrogateescape"`: a byte that is not UTF-8 survives the round trip).
941
+ - `is_drive(segment)` — whether a segment is a Windows drive (`"C:"`).
942
+
943
+ ## Provided by other distributions
944
+
945
+ `uripath` knows these schemes and this transport by name and does not contain
946
+ them:
947
+
948
+ | Name | Distribution | Install |
949
+ | --- | --- | --- |
950
+ | `sftp:` | `sftppath` | `pip install "sftppath[paramiko]"` (or `[asyncssh]`) |
951
+ | `s3:`, `gs:`, `az:` | `objpath` | `pip install "objpath[s3]"` (or `[gs]`, `[az]`) |
952
+ | `repo:`, `github:`, `gitlab:` | `repopath` | `pip install repopath` |
953
+ | the `requests` HTTP transport | `pathapi-requests` | `pip install pathapi-requests` |
954
+
955
+ A path of one of the schemes builds, joins, compares and prints without its
956
+ distribution, and is listed by `URIPath.schemes()`. Every operation that needs
957
+ the server (`stat()`, `exists()`, `open()`, `iterdir()`, a write, `rename()`,
958
+ `chmod()`, ...) raises `ImportError`, with a message that names the scheme and
959
+ the install command and never the URI (it can carry a password). So does
960
+ `URIPath.scheme_class(name)`. The installed distribution registers its own
961
+ class for the name, through an entry point of the group `uripath.schemes`, and
962
+ that class is used; its constructor takes the keywords that its `_OPTIONS`
963
+ names. An entry point never takes a built-in scheme over: `file`, `http` and
964
+ the other names `URIPath.schemes()` lists as built in keep their classes, and
965
+ a class that names one without `replace=True` is a `ValueError` where it is
966
+ defined.
967
+
968
+ For the `requests` transport, `HTTPURIPath.with_session()` and
969
+ `PATHAPI_HTTP_TRANSPORT=requests` use the class that the installed
970
+ `pathapi-requests` declares in the group `pathapi.http_transports`,
971
+ and raise `ImportError` naming it when it is not installed.
972
+
973
+ ## Exceptions
974
+
975
+ ### `uripath.exceptions`
976
+
977
+ ```python
978
+ class URIPathError(Exception): ...
979
+ class URISyntaxError(URIPathError, ValueError): ...
980
+ class UnsupportedSchemeError(URIPathError, ValueError): ...
981
+ class OutputClosedError(URIPathError, BrokenPipeError): ...
982
+ ```
983
+
984
+ `URIPathError` is the base of what goes wrong at run time; the root `uripath`
985
+ exports it and the two below.
986
+
987
+ - `URISyntaxError` — text that is not a URI, or not the part of one that was
988
+ asked for: a port that is not 0-65535, a malformed host or IPv6 address, a
989
+ `zip:` URI without the scheme of its archive, `Source.parse()` of text with a
990
+ path. The message names the
991
+ fault and never the text, which can carry a password.
992
+ - `UnsupportedSchemeError` — `URIPath.scheme_class(name)` for a scheme nobody
993
+ registered; the message lists the registered ones.
994
+ - `OutputClosedError` — `copy_stream()` found that the reader of its target
995
+ stream went away; only `uripath.exceptions` exports it.
996
+
997
+ A caller's bad argument stays a plain `ValueError` or `TypeError`: a name
998
+ another class holds (`ValueError` at the class statement), a path outside its
999
+ base (`relative_to()`), an object that is no path. Unsupported operations
1000
+ raise `NotImplementedError`. Network errors map to pathlib types
1001
+ (`FileNotFoundError`, `PermissionError`, `FileExistsError`,
1002
+ `IsADirectoryError`, `NotADirectoryError`, `OSError(ENOTEMPTY)`), timeouts to
1003
+ `TimeoutError`, anything else to `OSError`; transport exceptions are not
1004
+ chained (their text can carry credentials).
1005
+
1006
+ ## Command line
1007
+
1008
+ ### `uripath.cli` (`pip install "pathapi-uri[cli]"`)
1009
+
1010
+ ```python
1011
+ def main(argv: Sequence[str] | None = None) -> int: ...
1012
+ ```
1013
+
1014
+ The `uripath` console script is `uripath.cli:main`, and `python -m uripath` runs
1015
+ the same function. It is built on `duho` and needs the `cli` extra; `import
1016
+ uripath` never imports `uripath.cli` or `duho`, and `uripath.cli` is not library
1017
+ API (it is absent from the root `__all__`): its one public name is `main`. Without `duho`, `main()`
1018
+ prints `pip install "pathapi-uri[cli]"` to stderr and returns 1. `main()` returns
1019
+ the exit status and never raises `SystemExit`; it reads and writes the
1020
+ process's standard streams (binary through `sys.stdin.buffer` and
1021
+ `sys.stdout.buffer`).
1022
+
1023
+ Each command is a `duho` command class in `uripath.cli.<name>`; the root,
1024
+ `uripath.cli.Uripath`, is built on first use. Every command calls the library
1025
+ (`as_path()`, `copy_stream()`, `Path.copy()`, `Path.rm()`, `PathSyncer`), so
1026
+ nothing the command does is reachable only from it. A PATH is read by
1027
+ `as_path()`: an argument with `://`, or with a scheme some class registers
1028
+ (`data:`, `zip:`, ...), is a `URIPath`; everything else (including `C:/x` and
1029
+ `notes:draft`) is a `LocalPath`. `-` is stdin/stdout where bytes are read or
1030
+ written. `--` ends the options and takes no argument after it (a path that
1031
+ starts with `-` is written `./-name`).
1032
+
1033
+ | Subcommand | Arguments and flags |
1034
+ | --- | --- |
1035
+ | `read PATH` | Copy `PATH`'s bytes to stdout in chunks. |
1036
+ | `write PATH [DATA] [--encoding utf-8]` | Write `DATA` encoded, or stdin's bytes when omitted, replacing `PATH`. |
1037
+ | `rm PATH [-r/--recursive] [--missing-ok] [--ignore-error]` | `Path.rm()`; a directory with content needs `--recursive`. |
1038
+ | `cp SOURCE TARGET [-r/--recursive] [--overwrite] [--follow-symlinks/--no-follow-symlinks] [--preserve-metadata/--no-preserve-metadata]` | `Path.copy()`; with `-`, `copy_stream()` (an existing target needs `--overwrite`; no `-r`). |
1039
+ | `sync SOURCE TARGET [--dry-run] [--remove-missing] [--size-only] [--follow-symlinks/--no-follow-symlinks] [--follow-mounts/--no-follow-mounts]` | `PathSyncer` with content comparison; `--size-only` compares sizes; nothing is deleted without `--remove-missing`. `--dry-run` prints `would copy SRC -> DST`/`would remove`/`would mkdir`/`would replace`/`would symlink`; `-v` prints the changes made. `--no-follow-mounts` fails (exit 1, `OSError(EXDEV)`) on a mount point inside TARGET instead of syncing into it. |
1040
+
1041
+ Every command takes `-v`, `-q` and `--loglevel` (the `uripath` logger, on
1042
+ stderr). Results go to stdout, diagnostics to stderr, and no message carries a
1043
+ password or a token.
1044
+
1045
+ Exit status: 0 success (also `--help` and `--version`); 1 the operation failed,
1046
+ with one line `uripath: <Type>: <message>` on stderr, or the `cli` extra is
1047
+ missing; 2 a wrong invocation, with the usage or the problem on stderr (a
1048
+ missing or unknown argument, `cp -r` with `-`, an unknown `--encoding`); 130
1049
+ Ctrl-C; 141 the reader of stdout went away, quietly (on Windows too, where a
1050
+ closed pipe reports `EINVAL`).
1051
+
1052
+ Served as tools (`URIPATH_MCP=stdio uripath`) the five commands are the tools
1053
+ `uripath.read`, `uripath.write`, `uripath.rm`, `uripath.cp` and `uripath.sync`,
1054
+ with the schemas their fields give; `AGENT_HELP=1 uripath --help` prints one
1055
+ JSON document for the tree. A tool has no standard input: `-` as a source, and
1056
+ `write` without `DATA`, fail with a message. Every deleting or replacing
1057
+ option is off unless it is passed.
1058
+
1059
+ ## Environment variables
1060
+
1061
+ ### `PATHAPI_HTTP_TRANSPORT` (read by `pathapi.http`)
1062
+
1063
+ `builtin` (the default when unset), or the name of a transport an installed
1064
+ distribution declares in the group `pathapi.http_transports` (`requests`,
1065
+ from `pathapi-requests`): the transport that `http(s):` and `dav(s):`
1066
+ build for a backend that was not given one. `requests`
1067
+ without that distribution raises `ImportError` naming it. The value is
1068
+ case-sensitive, and an empty or unrecognised one raises `ValueError` naming the
1069
+ choices (`pathapi.http.transport_names()`). It is read when a backend first
1070
+ needs its transport, once per backend, so a change reaches backends built
1071
+ afterwards. `with_transport()` and `with_session()` do not read it.
1072
+
1073
+ ### Proxy variables (URI layer)
1074
+
1075
+ `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` and `NO_PROXY`, in upper or lower case:
1076
+ `HTTPTransport` reads them, through `urllib.request`, when it is built with
1077
+ `proxies=None`. A `proxies=` argument, `{}` included, is used instead and the
1078
+ environment is not read.
1079
+
1080
+ ### `URIPATH_MCP` and `AGENT_HELP` (command line)
1081
+
1082
+ `URIPATH_MCP=stdio` makes `uripath` serve its five commands as tools instead of
1083
+ running one; `AGENT_HELP=1` makes `uripath --help` print one JSON document for
1084
+ the command tree. `duho` reads both when the command starts; the library does
1085
+ not.
1086
+
1087
+ ## Gotchas
1088
+
1089
+ Importing `uripath` and constructing a path of any scheme does no I/O. A path
1090
+ joins, compares and prints without touching a server; the first operation that
1091
+ needs one (`exists()`, `stat()`, a read, a listing, `is_local()`) connects.
1092
+ That holds for the schemes another distribution provides as well: without the
1093
+ distribution their paths join, compare and print, and the first operation that
1094
+ needs the server raises `ImportError`.