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,338 @@
1
+ from __future__ import annotations
2
+
3
+ import re as _re
4
+ import typing as _ty
5
+
6
+ from pathapi.archive import ArchivePath, LinkTarget, TarPath, ZipPath
7
+ from pathapi.providers import as_error_handler, check_follow
8
+ from .._uri import URI
9
+ from ..exceptions import URISyntaxError
10
+ from ..source import SAFE_PATH, Source, percent_encode
11
+ from ._path import URIPath
12
+ from ._delegate import DelegatingURIPath
13
+
14
+ _SEP = "!/"
15
+ _SCHEME_RE = _re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
16
+ # Every scheme an `ArchiveURIPath` class registers.
17
+ _ARCHIVE_SCHEMES = ("zip", "tar", "archive", "archive+zip", "archive+tar")
18
+
19
+
20
+ def _nesting_depth(archive_uri: str) -> int:
21
+ """How many archive schemes `archive_uri` starts with: 1 for the
22
+ `zip:file:///outer.zip!/inner.zip` of a nested archive, 0 otherwise."""
23
+ depth = 0
24
+ match = _SCHEME_RE.match(archive_uri)
25
+ while match and archive_uri[: match.end() - 1].lower() in _ARCHIVE_SCHEMES:
26
+ depth += 1
27
+ archive_uri = archive_uri[match.end() :]
28
+ match = _SCHEME_RE.match(archive_uri)
29
+ return depth
30
+
31
+
32
+ def _split_archive_path(path: str) -> "tuple[str, str]":
33
+ """Split `<archive-uri>!/<inner-path>` (Java-style separator, as used
34
+ for JAR URLs / NIO ZipFileSystem) into its two halves. No separator (or
35
+ a bare trailing "!") means the archive root.
36
+
37
+ A nested archive's `<archive-uri>` is itself an archive URI with a
38
+ separator of its own (`zip:file:///outer.zip!/inner.zip!/x.txt`): each
39
+ leading archive scheme consumes one separator, and the split is at the
40
+ next one. A member name containing `!/` is written `%21/`."""
41
+ start = 0
42
+ for _ in range(_nesting_depth(path)):
43
+ index = path.find(_SEP, start)
44
+ if index < 0:
45
+ break
46
+ start = index + len(_SEP)
47
+ index = path.find(_SEP, start)
48
+ if index >= 0:
49
+ archive, inner = path[:index], path[index + len(_SEP) :]
50
+ elif path.endswith("!") and len(path) - 1 >= start:
51
+ archive, inner = path[:-1], ""
52
+ else:
53
+ archive, inner = path, ""
54
+ return archive, inner
55
+
56
+
57
+ def _parse_archive_uri(raw: str, scheme: str) -> "tuple[str, str, object, str] | None":
58
+ """Split a still percent-encoded `<scheme>:<archive-uri>!/<inner>`
59
+ string at its first literal `!/` BEFORE anything is decoded, so each
60
+ half is decoded exactly once: the outer by its own parse, the inner
61
+ here. Returns (outer URI, decoded inner path, query, fragment), or None
62
+ when `raw` does not start with `scheme`. An encoded `%21/` never
63
+ separates, and the inner path's dot segments stop at the archive root."""
64
+ prefix, sep, rest = raw.partition(":")
65
+ if not sep or prefix.lower() != scheme:
66
+ return None
67
+ archive, inner = _split_archive_path(rest)
68
+ _, path, query, fragment = URI._parse_uri("/" + inner.lstrip("/"))
69
+ return archive, path.lstrip("/"), query, fragment
70
+
71
+
72
+ def _open_outer(archive_uri: str, schemesmap=None) -> "URIPath":
73
+ if not _SCHEME_RE.match(archive_uri):
74
+ raise URISyntaxError(
75
+ f"Archive URI {archive_uri!r} has no scheme -- prefix it "
76
+ "explicitly, e.g. 'file:///path/to/archive.zip'."
77
+ )
78
+ return URIPath(archive_uri, schemesmap=schemesmap)
79
+
80
+
81
+ class ArchiveURIPath(DelegatingURIPath):
82
+ """Common base for `zip:`/`tar:`/`archive:` archive paths:
83
+ `<scheme>:<archive-uri>!/<inner-path>` (Java-style separator; the
84
+ `<archive-uri>` half is itself any absolute URI with an explicit scheme
85
+ -- `file:`, `http:`, `sftp:`, `ftp:`, ... -- so archives are readable
86
+ straight off any existing backend). `segments`/`name`/`parent`/`glob`/
87
+ ... all operate on the *inner* path; the outer archive handle is the
88
+ `backend`, propagated through the normal backend machinery to every path
89
+ derived from this one, and the members are read and written by a
90
+ `pathapi.archive.ArchivePath` (`ZipPath` or `TarPath` once the format
91
+ is known) on that handle.
92
+
93
+ Also registered directly as the `archive:` catch-all scheme (see
94
+ `__SCHEMES` below): `zip:`/`tar:` (via `ZipURIPath`/`TarURIPath`, which just
95
+ pin `_core_cls`) fix the format; plain `archive:` leaves it undecided
96
+ and settles it on first use. Writes are gated on the handle being
97
+ writable, which only a zip archive that is a local `file:` ever is -- so a
98
+ tar-backed instance, whether reached via `tar:` or auto-detected via
99
+ `archive:`, correctly raises `NotImplementedError` on any write attempt
100
+ rather than silently misbehaving."""
101
+
102
+ __SCHEMES = ("archive",)
103
+ __slots__ = ()
104
+ _core_cls: type = ArchivePath
105
+ _DELEGATED = (
106
+ "_scandir",
107
+ "_listdir",
108
+ "_open",
109
+ "_mkdir",
110
+ "unlink",
111
+ "rmdir",
112
+ "_write_batch",
113
+ "_node_key",
114
+ )
115
+
116
+ def _backend_for(self, source):
117
+ backend, derived = super()._backend_for(source)
118
+ if backend is None and not source:
119
+ # The sourceless `relative_to()` result is still a member path of
120
+ # this archive: its handle is what `_init` reads as "derived".
121
+ backend, derived = self._backend, self._backend_derived
122
+ return backend, derived
123
+
124
+ def _init(self, source, path, query, fragment, /, **kwargs):
125
+ self._inherit_backend(source)
126
+ backend = kwargs.get("backend", None) or self._backend
127
+ if backend is None:
128
+ # A fresh top-level construction: split the original, still-encoded string when
129
+ # there is one; `path` was decoded and dot-normalized as a whole, which merged
130
+ # the outer's query into ours and decoded the outer twice.
131
+ raw = self._raw_uris
132
+ parsed = None
133
+ if raw and len(raw) == 1 and isinstance(raw[0], str):
134
+ parsed = _parse_archive_uri(raw[0], source.scheme)
135
+ if parsed is not None:
136
+ archive_str, inner, query, fragment = parsed
137
+ else:
138
+ archive_str, inner = _split_archive_path(path)
139
+ inner = inner.lstrip("/")
140
+ outer = _open_outer(archive_str, self._schemes_in_use)
141
+ # `ZipURIPath`/`TarURIPath` pin the format; for `archive:` it is settled on
142
+ # first use, so building the path reads nothing.
143
+ backend = self._core_cls.backend_for(
144
+ outer,
145
+ # No class serves the outer's scheme (not in `schemesmap`, or
146
+ # unknown): it must not borrow a handle another path opened.
147
+ shared=type(outer) is not URIPath,
148
+ # A nested archive is named by its URI: asking it for its node
149
+ # would settle its format.
150
+ identity=(
151
+ outer._identity() if isinstance(outer, ArchiveURIPath) else None
152
+ ),
153
+ )
154
+ else:
155
+ # A derived instance: the backend is known and `path` is already the inner path.
156
+ # Member names never start with "/", but the generic `_make_child_relpath` joins
157
+ # a child of the root as "/name".
158
+ inner = path.lstrip("/")
159
+ kwargs["backend"] = backend
160
+ super()._init(source, inner, query, fragment, **kwargs)
161
+
162
+ def __new__(cls, *args, **kwargs):
163
+ inst = super().__new__(cls, *args, **kwargs)
164
+ if len(args) == 1 and isinstance(args[0], str) and not inst._raw_uris:
165
+ # Keep the undecoded string for `_init` (see `_parse_archive_uri`).
166
+ inst._raw_uris = [args[0]]
167
+ return inst
168
+
169
+ def _make_core(self):
170
+ return self._core_cls(self.path, archive=super().backend)
171
+
172
+ def refresh(self) -> None:
173
+ """Forgets what is held of the archive's outer file, so the next use
174
+ reads it again. A non-local archive is read once and kept while any
175
+ path to it lives, so this is how a later change at its source is
176
+ seen; a local one is checked against the file on every use. Every
177
+ path to the same archive shares the refresh."""
178
+ self._core_path.refresh()
179
+
180
+ def batch(self) -> "_ty.ContextManager[None]":
181
+ """A context manager that holds every change made to this archive
182
+ while it is open and writes them in one replacement of the archive
183
+ file when the outermost block ends without an exception; an exception
184
+ leaves the archive as it was. Inside it every path of the archive
185
+ reads the held changes, and the file is not touched until the block
186
+ ends. A block inside another joins it. `NotImplementedError` when the
187
+ archive cannot be written."""
188
+ with self._delegated():
189
+ return self._core_path.batch()
190
+
191
+ def readlink(self) -> LinkTarget:
192
+ """The text a link member stores, as a `LinkTarget` (`OSError(EINVAL)`
193
+ when this member is not a link). Links on the way to it are followed."""
194
+ with self._delegated():
195
+ return self._core_path.readlink()
196
+
197
+ @property
198
+ def backend(self):
199
+ return self._core_path.backend
200
+
201
+ def _outer_path(self) -> "URIPath":
202
+ """The archive's own path. Never decides the format."""
203
+ return super().backend.outer
204
+
205
+ def as_uri(self, /, sanitize=False):
206
+ if not self.source:
207
+ # A derived relative path (`relative_to`): no archive to name.
208
+ return super().as_uri(sanitize=sanitize)
209
+ # Encoded so the string parses back to the same member: the inner
210
+ # path's `%`, `?` and `#`, and a literal "!/" inside the outer URI.
211
+ outer = self._outer_path()
212
+ outer_uri = outer.as_uri(sanitize=sanitize)
213
+ if not isinstance(outer, ArchiveURIPath):
214
+ # A nested archive's outer keeps its own separator (it already
215
+ # encodes any other "!/"); see `_split_archive_path`.
216
+ outer_uri = outer_uri.replace(_SEP, "%21/")
217
+ # The composer's own encoding: a name that is not UTF-8 (a tar member
218
+ # written in another charset) keeps its bytes as `%XX`.
219
+ inner = percent_encode(self.path, SAFE_PATH)
220
+ # A "!/" inside a member name must never read as a separator once
221
+ # this URI is itself the outer of a nested archive.
222
+ inner = inner.replace(_SEP, "%21/")
223
+ tail = self._format_parsed_parts(
224
+ Source(None, None, None, None), "", self.query, self.fragment
225
+ )
226
+ return f"{self.source.scheme}:{outer_uri}{_SEP}{inner}{tail}"
227
+
228
+ def rm(
229
+ self,
230
+ /,
231
+ recursive=False,
232
+ missing_ok=False,
233
+ ignore_error: "bool | _ty.Callable[[Exception, _ty.Self], bool]" = False,
234
+ *,
235
+ follow_symlinks=False,
236
+ follow_binds=False,
237
+ ):
238
+ # An archive holds no bindings, and a link member is an entry like
239
+ # any other for a removal that does not follow it.
240
+ check_follow("follow_symlinks", follow_symlinks)
241
+ check_follow("follow_binds", follow_binds)
242
+ if not (
243
+ recursive
244
+ and self._rm_tree(
245
+ as_error_handler(ignore_error), follows=follow_symlinks is not False
246
+ )
247
+ ):
248
+ return super().rm(
249
+ recursive=recursive,
250
+ missing_ok=missing_ok,
251
+ ignore_error=ignore_error,
252
+ follow_symlinks=follow_symlinks,
253
+ follow_binds=follow_binds,
254
+ )
255
+
256
+ def _rm_tree(self, on_error, follows: bool = False) -> bool:
257
+ """The one-rewrite removal of a directory of a writable archive; False
258
+ when it is not that case. `on_error` is asked about a failure with this
259
+ path."""
260
+ with self._delegated():
261
+ return self._core_path._rm_tree(
262
+ lambda error, _path: on_error(error, self), follows
263
+ )
264
+
265
+ def _same_filesystem(self, other) -> bool:
266
+ # The archive handle is the namespace. An `archive:` path holds a
267
+ # stand-in until its format is settled, and two of them naming one
268
+ # archive settle on one handle.
269
+ if isinstance(other, ArchiveURIPath):
270
+ try:
271
+ return other.backend is self.backend
272
+ except Exception:
273
+ # The archive cannot be read: nothing settles its handle.
274
+ return False
275
+ return super()._same_filesystem(other)
276
+
277
+ def _same_location(self, other: URI) -> bool:
278
+ # Every archive URI has the same bare "zip:"/"tar:" authority, so the
279
+ # authority alone cannot tell two archives apart: an archive path is
280
+ # only renameable onto a member of the very same archive (backend).
281
+ if isinstance(other, ArchiveURIPath):
282
+ return other.backend is self.backend
283
+ if isinstance(other, URIPath):
284
+ return False
285
+ return super()._same_location(other)
286
+
287
+ def rename(self, target: "ArchiveURIPath | URI | str"):
288
+ # A plain str target is a sibling rename (relative to self's parent), as in sftp
289
+ # and ftp. An existing target is replaced as POSIX rename(2) does (a file over a
290
+ # file, a directory over an empty one), never a duplicate member. Returns the new path.
291
+ target = self._rename_target(target)
292
+ core = self._core_path
293
+ moved = core.with_segments(target.path.lstrip("/"))
294
+ destination = self._from_parsed_parts(
295
+ self.source, target.path.lstrip("/"), "", ""
296
+ )
297
+ with self._delegated((str(moved), str(destination))):
298
+ renamed = core.rename(moved)
299
+ return self._from_parsed_parts(
300
+ self.source, renamed.as_posix().lstrip("/"), "", ""
301
+ )
302
+
303
+
304
+ class ZipURIPath(ArchiveURIPath):
305
+ """`zip:` scheme (`archive+zip:` is a second name for it). Read/write: write support (new entries, overwriting
306
+ existing entries, `unlink`/`rmdir`/`rename`) works when the outer
307
+ archive is a local `file:` URI; every other outer scheme is read-only
308
+ (fetched fully into memory first). Every mutation replaces the archive
309
+ atomically (temp file + `os.replace`), so the result is a new file: other
310
+ hard links keep the old content, and an archive the caller may not write
311
+ is refused first. A new entry is appended, deflated, to a byte copy of the
312
+ archive (nothing recompressed); overwriting/deleting/renaming an existing
313
+ entry rewrites the whole archive, since the standard library's zip module has no in-place
314
+ entry mutation, keeping every other entry's metadata and compression method.
315
+ The members are read and written by a `pathapi.archive.ZipPath`."""
316
+
317
+ __SCHEMES = ("zip", "archive+zip")
318
+ __slots__ = ()
319
+ _core_cls = ZipPath
320
+
321
+
322
+ class TarURIPath(ArchiveURIPath):
323
+ """`tar:` scheme (also handles `.tar.gz`/`.tar.bz2`/`.tar.xz` via
324
+ the standard library's auto-detected "r:*" mode). Read-only. Members stored with a
325
+ `./` prefix are addressed without it. `archive+tar:` is the same scheme
326
+ under a second name. The members are read by a
327
+ `pathapi.archive.TarPath`."""
328
+
329
+ __SCHEMES = ("tar", "archive+tar")
330
+ __slots__ = ()
331
+ _core_cls = TarPath
332
+
333
+
334
+ # `archive+zip:` and `archive+tar:` are second scheme names of `ZipURIPath` and
335
+ # `TarURIPath` (they pin the format, which the extension-detecting `archive:`
336
+ # does not); these class names stay importable.
337
+ ArchiveZipURIPath = ZipURIPath
338
+ ArchiveTarURIPath = TarURIPath
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import io as _io
4
+ from base64 import b64decode as _b64decode
5
+
6
+ from pathapi import FileStat
7
+ from pathapi import providers as _providers
8
+ from .. import _rfc3986
9
+ from ._path import URIPath
10
+ from ..source import ERRORS
11
+
12
+ _DEFAULT_MEDIATYPE = "text/plain;charset=US-ASCII"
13
+
14
+
15
+ class DataURIPath(URIPath):
16
+ """`data:` scheme (RFC 2397): a read-only "file" whose entire content is
17
+ embedded in the URI itself (`data:[<mediatype>][;base64],<data>`) -- no
18
+ filesystem, no directories, no backend/connection. RFC 2397 has no query,
19
+ so an unescaped `?` and what follows it are payload; a `#` starts the
20
+ fragment, as in any URI, and is not."""
21
+
22
+ __SCHEMES = ("data",)
23
+ __slots__ = ()
24
+
25
+ @property
26
+ def _header(self) -> str:
27
+ header, _, _ = self.path.partition(",")
28
+ return header
29
+
30
+ @property
31
+ def _is_base64(self) -> bool:
32
+ # RFC 2397: the extension is ";base64". "data:base64,..." has none;
33
+ # there "base64" is the (malformed) media type, not an encoding.
34
+ params = self._header.rsplit(";", 1)
35
+ return len(params) == 2 and params[1].strip().lower() == "base64"
36
+
37
+ @property
38
+ def mediatype(self) -> str:
39
+ """The declared media type. Without a type/subtype, RFC 2397 implies
40
+ "text/plain": a bare header gives "text/plain;charset=US-ASCII",
41
+ and parameters alone (";charset=utf-8") are given that type."""
42
+ header = self._header
43
+ if self._is_base64:
44
+ header = header.rsplit(";", 1)[0]
45
+ if not header:
46
+ return _DEFAULT_MEDIATYPE
47
+ if header.startswith(";"):
48
+ return "text/plain" + header
49
+ return header
50
+
51
+ @property
52
+ def charset(self) -> str:
53
+ """The charset the media type declares (`charset=utf-8`, quotes
54
+ removed), or `"utf-8"` when it declares none: a text payload is read
55
+ in it by `open()` and `read_text()` unless `encoding=` says
56
+ otherwise. UTF-8 reads what the RFC's US-ASCII default names, and more."""
57
+ header = self._header
58
+ if self._is_base64:
59
+ header = header.rsplit(";", 1)[0]
60
+ for parameter in header.split(";")[1:]:
61
+ name, _, value = parameter.partition("=")
62
+ if name.strip().lower() == "charset":
63
+ return value.strip().strip("\"'") or "utf-8"
64
+ return "utf-8"
65
+
66
+ def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None):
67
+ if encoding is None and "b" not in mode:
68
+ encoding = self.charset
69
+ return super().open(mode, buffering, encoding, errors, newline)
70
+
71
+ def _content(self) -> bytes:
72
+ text = self.path
73
+ if self.query:
74
+ # Decoded like the path: `.query` is kept as received.
75
+ text += "?" + _rfc3986.decode(str(self.query), ERRORS)
76
+ header, sep, data = text.partition(",")
77
+ if not sep:
78
+ raise FileNotFoundError(self)
79
+ # `.path` is already percent-decoded exactly once (and, for data:, not
80
+ # dot-normalized), with any non-UTF-8 byte carried as a surrogate: re-encode, do
81
+ # not unquote a second time ("100%2525" must give b"100%25").
82
+ payload = data.encode("utf-8", ERRORS)
83
+ if self._is_base64:
84
+ return _b64decode(payload)
85
+ return payload
86
+
87
+ def stat(self, *, follow_symlinks=True):
88
+ return FileStat(st_size=len(self._content()), is_dir=False)
89
+
90
+ def _open(self, mode="r", buffering=-1):
91
+ # Exactly "r": "r+" returned a writable buffer whose writes were
92
+ # silently discarded.
93
+ if mode != "r":
94
+ raise NotImplementedError("data: URIs are read-only")
95
+ return _io.BufferedReader(_io.BytesIO(self._content()))
96
+
97
+ @_providers.primitive
98
+ def _listdir(self):
99
+ raise NotADirectoryError(self)