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.
- pathapi_uri-0.1.0.dist-info/METADATA +259 -0
- pathapi_uri-0.1.0.dist-info/RECORD +48 -0
- pathapi_uri-0.1.0.dist-info/WHEEL +4 -0
- pathapi_uri-0.1.0.dist-info/entry_points.txt +17 -0
- pathapi_uri-0.1.0.dist-info/licenses/LICENSE +21 -0
- uripath/AGENTS.md +1094 -0
- uripath/README.md +213 -0
- uripath/__init__.py +27 -0
- uripath/__main__.py +7 -0
- uripath/_pathtext.py +32 -0
- uripath/_rfc3986.py +67 -0
- uripath/_transfer.py +108 -0
- uripath/_uri.py +1007 -0
- uripath/_version.py +11 -0
- uripath/cli/__init__.py +54 -0
- uripath/cli/_common.py +48 -0
- uripath/cli/_root.py +70 -0
- uripath/cli/cp.py +61 -0
- uripath/cli/read.py +22 -0
- uripath/cli/rm.py +38 -0
- uripath/cli/sync.py +78 -0
- uripath/cli/write.py +49 -0
- uripath/exceptions.py +38 -0
- uripath/py.typed +1 -0
- uripath/query.py +240 -0
- uripath/schemes/__init__.py +120 -0
- uripath/schemes/_archive.py +338 -0
- uripath/schemes/_data.py +99 -0
- uripath/schemes/_dav.py +459 -0
- uripath/schemes/_delegate.py +127 -0
- uripath/schemes/_elsewhere.py +102 -0
- uripath/schemes/_file.py +186 -0
- uripath/schemes/_ftp.py +200 -0
- uripath/schemes/_http/__init__.py +4 -0
- uripath/schemes/_http/_backend.py +132 -0
- uripath/schemes/_http/_headers.py +8 -0
- uripath/schemes/_http/_listing.py +403 -0
- uripath/schemes/_http/_path.py +371 -0
- uripath/schemes/_http/_settings.py +6 -0
- uripath/schemes/_http/_streams.py +230 -0
- uripath/schemes/_httpdate.py +48 -0
- uripath/schemes/_path.py +629 -0
- uripath/schemes/_registry.py +134 -0
- uripath/source/__init__.py +20 -0
- uripath/source/_compose.py +212 -0
- uripath/source/_locality.py +44 -0
- uripath/source/_parse.py +183 -0
- 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
|
uripath/schemes/_data.py
ADDED
|
@@ -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)
|