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,371 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno as _errno
|
|
4
|
+
import os as _os
|
|
5
|
+
import re as _re
|
|
6
|
+
import typing as _ty
|
|
7
|
+
import urllib.parse as _urlparse
|
|
8
|
+
|
|
9
|
+
import pathapi.http as _http
|
|
10
|
+
from pathapi import FileStat
|
|
11
|
+
from pathapi import providers as _providers
|
|
12
|
+
from pathapi.http import (
|
|
13
|
+
HTTPResponse,
|
|
14
|
+
drop_body,
|
|
15
|
+
is_redirect,
|
|
16
|
+
os_error,
|
|
17
|
+
raise_for_status,
|
|
18
|
+
server_size,
|
|
19
|
+
translate_errors,
|
|
20
|
+
)
|
|
21
|
+
from .. import _httpdate
|
|
22
|
+
from .._path import URIPath
|
|
23
|
+
from . import _settings
|
|
24
|
+
from ._backend import HTTPBackend, _DerivedHTTPBackend
|
|
25
|
+
from ._headers import _IDENTITY_ENCODING
|
|
26
|
+
from ._listing import _DirectoryListingParser, _FileEntry
|
|
27
|
+
from ._streams import (
|
|
28
|
+
HTTPAppendStream,
|
|
29
|
+
HTTPWriteStream,
|
|
30
|
+
_ResponseReader,
|
|
31
|
+
_accept,
|
|
32
|
+
_charset,
|
|
33
|
+
_response_reader,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_CHUNK = 64 * 1024
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# A listing is parsed only from an HTML reply (or one with no Content-Type).
|
|
40
|
+
_LISTING_TYPES = ("text/html", "application/xhtml+xml")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_RE_URL_SCHEME = _re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HTTPURIPath(URIPath):
|
|
47
|
+
"""`http`/`https` scheme: read/write access over HTTP (`PUT`/`DELETE`
|
|
48
|
+
for writes/deletes, configurable via `with_transport()`), listing
|
|
49
|
+
directories by scraping an Apache/nginx-style HTML index with a
|
|
50
|
+
zero-dependency in-house parser (`_DirectoryListingParser`). Needs no
|
|
51
|
+
third-party library: requests go through `HTTPTransport` unless a
|
|
52
|
+
transport is given."""
|
|
53
|
+
|
|
54
|
+
__SCHEMES = ("http", "https")
|
|
55
|
+
__slots__ = ()
|
|
56
|
+
|
|
57
|
+
if _ty.TYPE_CHECKING:
|
|
58
|
+
backend: HTTPBackend
|
|
59
|
+
|
|
60
|
+
def _initbackend(self):
|
|
61
|
+
return _DerivedHTTPBackend(timeout=_settings.DEFAULT_TIMEOUT)
|
|
62
|
+
|
|
63
|
+
def _make_child_relpath(self, name, stat_hint=None, **kwargs):
|
|
64
|
+
"""The child `name`. A name given by the caller keeps the query and
|
|
65
|
+
fragment like every join. An entry of a listing (it comes with its
|
|
66
|
+
stat) is addressed by its href resolved against the directory
|
|
67
|
+
(RFC 3986 5.2), so the query that shapes the index (`?sort=name`) and
|
|
68
|
+
the fragment belong to the request for the listing, not to the entry."""
|
|
69
|
+
if stat_hint is None:
|
|
70
|
+
return super()._make_child_relpath(name, **kwargs)
|
|
71
|
+
directory = self.path if self.path.endswith("/") else self.path + "/"
|
|
72
|
+
child = self._from_parsed_parts(self.source, directory + name, "", "", **kwargs)
|
|
73
|
+
child._stat_hint = stat_hint
|
|
74
|
+
return child
|
|
75
|
+
|
|
76
|
+
def _listdir(self) -> list[_FileEntry]:
|
|
77
|
+
# The slash form first: a server redirects a directory's slash-less URL to it, at a
|
|
78
|
+
# round trip per listing. One that answers 404 for it (a file, or a server that
|
|
79
|
+
# serves the bare path only) is asked again for the path as given.
|
|
80
|
+
slashed = self if self.path.endswith("/") else self.with_path(self.path + "/")
|
|
81
|
+
try:
|
|
82
|
+
req = self._get(slashed)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
if slashed is self:
|
|
85
|
+
raise
|
|
86
|
+
req = self._get(self)
|
|
87
|
+
try:
|
|
88
|
+
content_type = req.headers.get("Content-Type") or ""
|
|
89
|
+
mime = content_type.split(";", 1)[0].strip().lower()
|
|
90
|
+
if mime and mime not in _LISTING_TYPES:
|
|
91
|
+
# A file: pathlib's iterdir() raises, and its body -- maybe
|
|
92
|
+
# gigabytes -- is never downloaded to look for links.
|
|
93
|
+
raise os_error(NotADirectoryError, self)
|
|
94
|
+
text = self._read_listing(req)
|
|
95
|
+
finally:
|
|
96
|
+
req.close()
|
|
97
|
+
# Scoped by the URL actually answered (after redirects), not by the
|
|
98
|
+
# page's own <title>.
|
|
99
|
+
parser = _DirectoryListingParser(base_url=req.url)
|
|
100
|
+
parser.feed(text)
|
|
101
|
+
parser.close()
|
|
102
|
+
return parser.listing
|
|
103
|
+
|
|
104
|
+
def _read_listing(self, req: HTTPResponse) -> str:
|
|
105
|
+
"""The body of the directory index `req` as text, read the way
|
|
106
|
+
`open()` reads a file: a cut-short, stalled or undecodable body
|
|
107
|
+
raises `TimeoutError`/`OSError` naming the path, and one longer than
|
|
108
|
+
`pathapi.http.MAX_REPLY_BYTES` raises `OSError(EFBIG)`."""
|
|
109
|
+
limit = _http.MAX_REPLY_BYTES
|
|
110
|
+
too_large = OSError(
|
|
111
|
+
_errno.EFBIG,
|
|
112
|
+
f"Directory index of {self} is larger than {limit} bytes "
|
|
113
|
+
"(MAX_REPLY_BYTES)",
|
|
114
|
+
)
|
|
115
|
+
stated = server_size(req.headers.get("Content-Length"))
|
|
116
|
+
if stated is not None and stated > limit:
|
|
117
|
+
raise too_large
|
|
118
|
+
reader = _ResponseReader(self, req, _CHUNK)
|
|
119
|
+
chunks = []
|
|
120
|
+
size = 0
|
|
121
|
+
while chunk := reader.read(_CHUNK):
|
|
122
|
+
size += len(chunk)
|
|
123
|
+
if size > limit:
|
|
124
|
+
raise too_large
|
|
125
|
+
chunks.append(chunk)
|
|
126
|
+
body = b"".join(chunks)
|
|
127
|
+
try:
|
|
128
|
+
return body.decode(_charset(req.headers), "replace")
|
|
129
|
+
except LookupError:
|
|
130
|
+
return body.decode("utf-8", "replace")
|
|
131
|
+
|
|
132
|
+
def _get(self, uri, **kwargs) -> HTTPResponse:
|
|
133
|
+
"""A streamed `GET` of `uri` that succeeded, its body unread. A
|
|
134
|
+
failure is translated to the pathlib exception for this path, and the
|
|
135
|
+
connection is released first: `stream=True` leaves it held."""
|
|
136
|
+
with translate_errors(self):
|
|
137
|
+
req = self.backend.request("GET", uri, stream=True, **kwargs)
|
|
138
|
+
try:
|
|
139
|
+
raise_for_status(req)
|
|
140
|
+
except BaseException:
|
|
141
|
+
drop_body(req)
|
|
142
|
+
raise
|
|
143
|
+
return req
|
|
144
|
+
|
|
145
|
+
def _scandir(self):
|
|
146
|
+
# `_listdir()`'s single GET already carries type/size/mtime for
|
|
147
|
+
# every child -- reuse it instead of `iterdir()` + a HEAD per child.
|
|
148
|
+
for entry in self._listdir():
|
|
149
|
+
# A subdirectory entry carries a trailing "/" (htmllistparse's name is "sub/"):
|
|
150
|
+
# strip it, or the child's path ends in "/" and `Pathname.name` (the last segment)
|
|
151
|
+
# is "".
|
|
152
|
+
is_dir = entry.name.endswith("/")
|
|
153
|
+
name = entry.name.removesuffix("/")
|
|
154
|
+
if not _providers.is_safe_child_name(name):
|
|
155
|
+
# A listing is untrusted: wsgidav renders its parent row as `<a href="..">` and a
|
|
156
|
+
# `<pre>` index can carry `./`. "." or ".." as a child would make `unlink()` DELETE
|
|
157
|
+
# the parent collection and `walk()` loop forever. Filtered here for every parser.
|
|
158
|
+
continue
|
|
159
|
+
if is_dir:
|
|
160
|
+
stat = FileStat(
|
|
161
|
+
st_size=0, st_mtime=_httpdate.parsedate(entry.modified), is_dir=True
|
|
162
|
+
)
|
|
163
|
+
elif entry.size is not None and entry.size_exact and entry.modified:
|
|
164
|
+
stat = FileStat(
|
|
165
|
+
st_size=entry.size,
|
|
166
|
+
st_mtime=_httpdate.parsedate(entry.modified),
|
|
167
|
+
is_dir=False,
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
# No exact byte count or no date in the listing (the stdlib `<ul>` index, a "1.2K"
|
|
171
|
+
# column): the entry is a file, but a size or time would be made up, so the hint is
|
|
172
|
+
# type-only and `stat()` asks the server.
|
|
173
|
+
stat = FileStat(is_dir=False, size_known=False)
|
|
174
|
+
yield name, stat
|
|
175
|
+
|
|
176
|
+
def _is_dir(self, resp: HTTPResponse):
|
|
177
|
+
# Judged on the FINAL response, after redirects (http->https, CDN and "latest"
|
|
178
|
+
# download links redirect to files), by the path of that URL: a query or fragment
|
|
179
|
+
# does not make a directory a file.
|
|
180
|
+
return _urlparse.urlsplit(resp.url).path.endswith(("/", "/..", "/."))
|
|
181
|
+
|
|
182
|
+
def stat(self, *, follow_symlinks=True, walk_up_last_modified=False):
|
|
183
|
+
hint = self._pop_stat_hint()
|
|
184
|
+
if hint is not None:
|
|
185
|
+
return hint
|
|
186
|
+
return self._head_stat(walk_up_last_modified)[0]
|
|
187
|
+
|
|
188
|
+
def _head_stat(self, walk_up_last_modified=False) -> "tuple[FileStat, bool]":
|
|
189
|
+
"""`stat()` without the listing hint, and whether the server stated
|
|
190
|
+
the size: a file whose HEAD reply has no `Content-Length` reports
|
|
191
|
+
`st_size` 0, which is not its length."""
|
|
192
|
+
with translate_errors(self):
|
|
193
|
+
# The caller's own spelling first: a trailing-slash path the
|
|
194
|
+
# server answers is a directory, even when the slash-less URL
|
|
195
|
+
# answers too (wsgidav serves both `/d` and `/d/` with 200).
|
|
196
|
+
check = (
|
|
197
|
+
[self, self.with_path(self.path.removesuffix("/"))]
|
|
198
|
+
if self.path.endswith("/")
|
|
199
|
+
else [self]
|
|
200
|
+
)
|
|
201
|
+
for uri in check:
|
|
202
|
+
resp = self.backend.request(
|
|
203
|
+
"HEAD", uri, allow_redirects=False, headers=_IDENTITY_ENCODING
|
|
204
|
+
)
|
|
205
|
+
resp.close()
|
|
206
|
+
if resp.status == 405:
|
|
207
|
+
# Some servers reject HEAD outright; fall back to GET.
|
|
208
|
+
resp = self.backend.request(
|
|
209
|
+
"GET",
|
|
210
|
+
uri,
|
|
211
|
+
allow_redirects=False,
|
|
212
|
+
stream=True,
|
|
213
|
+
headers=_IDENTITY_ENCODING,
|
|
214
|
+
)
|
|
215
|
+
resp.close()
|
|
216
|
+
if resp.status < 400:
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
if is_redirect(resp):
|
|
220
|
+
# Where the first answer pointed, not the same request again;
|
|
221
|
+
# `redirect()` knows the origin that `Authorization` is for.
|
|
222
|
+
moved = resp
|
|
223
|
+
resp = self.backend.redirect(
|
|
224
|
+
"HEAD", uri, moved, headers=_IDENTITY_ENCODING
|
|
225
|
+
)
|
|
226
|
+
resp.close()
|
|
227
|
+
if resp.status == 405:
|
|
228
|
+
# The HEAD-405 fallback again after a redirect: a server or proxy that rejects HEAD
|
|
229
|
+
# everywhere would otherwise raise PermissionError for an existing path.
|
|
230
|
+
resp = self.backend.redirect(
|
|
231
|
+
"GET", uri, moved, stream=True, headers=_IDENTITY_ENCODING
|
|
232
|
+
)
|
|
233
|
+
resp.close()
|
|
234
|
+
raise_for_status(resp)
|
|
235
|
+
# From the final URL, once any redirect has been followed.
|
|
236
|
+
is_dir = self._is_dir(resp)
|
|
237
|
+
|
|
238
|
+
# A missing, non-numeric or negative Content-Length is no size.
|
|
239
|
+
stated = None if is_dir else server_size(resp.headers.get("Content-Length"))
|
|
240
|
+
st_size = stated or 0
|
|
241
|
+
lm = resp.headers.get("Last-Modified")
|
|
242
|
+
if lm is None and walk_up_last_modified:
|
|
243
|
+
parent = self.parent
|
|
244
|
+
if self != parent:
|
|
245
|
+
try:
|
|
246
|
+
entry = next(
|
|
247
|
+
filter(
|
|
248
|
+
lambda p: p.name.removesuffix("/") == self.name,
|
|
249
|
+
parent._listdir(),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
if entry and entry.modified:
|
|
253
|
+
lm = entry.modified
|
|
254
|
+
except (StopIteration, OSError):
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
size_stated = is_dir or stated is not None
|
|
258
|
+
stat = FileStat(
|
|
259
|
+
st_size=st_size, st_mtime=_httpdate.parsedate(lm), is_dir=is_dir
|
|
260
|
+
)
|
|
261
|
+
return stat, size_stated
|
|
262
|
+
|
|
263
|
+
def _ensure_missing(self) -> None:
|
|
264
|
+
"""Raise `FileExistsError` unless `stat()` says the path is not
|
|
265
|
+
found. Any other failure of the probe (a 5xx, a 403, a timeout)
|
|
266
|
+
propagates: it must not read as permission to create."""
|
|
267
|
+
try:
|
|
268
|
+
self.stat()
|
|
269
|
+
except FileNotFoundError:
|
|
270
|
+
return
|
|
271
|
+
raise os_error(FileExistsError, self)
|
|
272
|
+
|
|
273
|
+
def _open(
|
|
274
|
+
self,
|
|
275
|
+
mode="r",
|
|
276
|
+
buffering=-1,
|
|
277
|
+
):
|
|
278
|
+
if mode == "r":
|
|
279
|
+
req = self._get(self.as_uri(), headers=_IDENTITY_ENCODING)
|
|
280
|
+
return _response_reader(self, req, buffering)
|
|
281
|
+
if mode == "a":
|
|
282
|
+
return HTTPAppendStream(self)
|
|
283
|
+
if mode not in ("w", "x"):
|
|
284
|
+
raise NotImplementedError(f"open(mode={mode!r})")
|
|
285
|
+
if mode == "x":
|
|
286
|
+
self._ensure_missing()
|
|
287
|
+
return HTTPWriteStream(self, exclusive=mode == "x")
|
|
288
|
+
|
|
289
|
+
def unlink(self, missing_ok=False):
|
|
290
|
+
# A server that honours DELETE on a collection (WebDAV, RFC 4918) removes the whole
|
|
291
|
+
# tree, and unlink() never removes a directory: refuse one. Over http: this uses the
|
|
292
|
+
# HEAD `stat()` heuristic, which misses some collections (see `dav:` for reliable ones).
|
|
293
|
+
if self.is_dir():
|
|
294
|
+
raise IsADirectoryError(
|
|
295
|
+
_errno.EISDIR, _os.strerror(_errno.EISDIR), str(self)
|
|
296
|
+
)
|
|
297
|
+
self._delete(missing_ok=missing_ok)
|
|
298
|
+
|
|
299
|
+
def _delete(self, missing_ok=False):
|
|
300
|
+
# The raw DELETE, with no collection guard: `rmdir()` has already
|
|
301
|
+
# verified an empty directory before calling it.
|
|
302
|
+
with translate_errors(self):
|
|
303
|
+
resp = self.backend.request("DELETE", self, stream=True)
|
|
304
|
+
if resp.status == 404:
|
|
305
|
+
drop_body(resp)
|
|
306
|
+
if missing_ok:
|
|
307
|
+
return
|
|
308
|
+
raise FileNotFoundError(self)
|
|
309
|
+
_accept(resp)
|
|
310
|
+
|
|
311
|
+
def rmdir(self):
|
|
312
|
+
# An empty directory's listing and a file whose body parses to zero entries look
|
|
313
|
+
# alike from `_listdir()`: without this check rmdir() of a file would DELETE it
|
|
314
|
+
# instead of raising NotADirectoryError.
|
|
315
|
+
if not self.is_dir():
|
|
316
|
+
raise NotADirectoryError(self)
|
|
317
|
+
# `_scandir()`, not the raw `_listdir()`: a listing's own "."/".."
|
|
318
|
+
# rows are not children and must not make an empty directory
|
|
319
|
+
# look non-empty.
|
|
320
|
+
for _ in self._scandir():
|
|
321
|
+
raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
|
|
322
|
+
self._delete()
|
|
323
|
+
|
|
324
|
+
def with_transport(
|
|
325
|
+
self,
|
|
326
|
+
transport,
|
|
327
|
+
*,
|
|
328
|
+
headers=None,
|
|
329
|
+
timeout=_settings.DEFAULT_TIMEOUT,
|
|
330
|
+
write_method: str = "PUT",
|
|
331
|
+
append_mode: str = "rewrite",
|
|
332
|
+
):
|
|
333
|
+
"""This path with a backend that sends through `transport` (any object
|
|
334
|
+
with a `send()` method as `pathapi.http.HTTPTransport.send()`;
|
|
335
|
+
the caller closes
|
|
336
|
+
it). `headers` go with every request; `write_method` (`"PUT"`, or
|
|
337
|
+
e.g. `"POST"`) uploads on close, and `append_mode` is `"rewrite"` or
|
|
338
|
+
`"patch"`."""
|
|
339
|
+
backend = HTTPBackend(
|
|
340
|
+
transport,
|
|
341
|
+
headers=headers,
|
|
342
|
+
timeout=timeout,
|
|
343
|
+
write_method=write_method,
|
|
344
|
+
append_mode=append_mode,
|
|
345
|
+
)
|
|
346
|
+
return type(self)(self, backend=backend)
|
|
347
|
+
|
|
348
|
+
def with_session(
|
|
349
|
+
self,
|
|
350
|
+
session,
|
|
351
|
+
write_method: str = "PUT",
|
|
352
|
+
append_mode: str = "rewrite",
|
|
353
|
+
**requests_args,
|
|
354
|
+
):
|
|
355
|
+
"""This path with a backend that sends through a `requests.Session`
|
|
356
|
+
(`session=None` makes a new one). `headers` and `timeout` in
|
|
357
|
+
`requests_args` are the backend's; the others (`auth`, `verify`,
|
|
358
|
+
`proxies`, `cert`, ...) go to the `requests` transport of
|
|
359
|
+
`pathapi-requests`, which must be installed: without it this raises
|
|
360
|
+
`ImportError` naming it. The caller closes the transport."""
|
|
361
|
+
from pathapi.http import get_transport
|
|
362
|
+
|
|
363
|
+
headers = requests_args.pop("headers", None)
|
|
364
|
+
timeout = requests_args.pop("timeout", _settings.DEFAULT_TIMEOUT)
|
|
365
|
+
return self.with_transport(
|
|
366
|
+
get_transport("requests")(session, **requests_args),
|
|
367
|
+
headers=headers,
|
|
368
|
+
timeout=timeout,
|
|
369
|
+
write_method=write_method,
|
|
370
|
+
append_mode=append_mode,
|
|
371
|
+
)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno as _errno
|
|
4
|
+
import io as _io
|
|
5
|
+
import typing as _ty
|
|
6
|
+
|
|
7
|
+
from pathapi import providers as _providers
|
|
8
|
+
from pathapi.http import (
|
|
9
|
+
HTTPResponse,
|
|
10
|
+
drop_body,
|
|
11
|
+
os_error,
|
|
12
|
+
raise_for_status,
|
|
13
|
+
translate_errors,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if _ty.TYPE_CHECKING:
|
|
17
|
+
from ._path import HTTPURIPath
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _charset(headers) -> str:
|
|
21
|
+
"""The charset a `Content-Type` names, `utf-8` when it names none."""
|
|
22
|
+
for part in (headers.get("Content-Type") or "").split(";")[1:]:
|
|
23
|
+
name, _, value = part.partition("=")
|
|
24
|
+
if name.strip().lower() == "charset":
|
|
25
|
+
return value.strip().strip("\"'") or "utf-8"
|
|
26
|
+
return "utf-8"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _ResponseReader(_io.RawIOBase):
|
|
30
|
+
"""Raw read stream over a streamed response body.
|
|
31
|
+
|
|
32
|
+
The transport has decoded a body the server encoded anyway (despite
|
|
33
|
+
`Accept-Encoding: identity`), and a failure in the middle of the body
|
|
34
|
+
surfaces as `TimeoutError`/`ConnectionError`/`OSError` naming the path.
|
|
35
|
+
A decoded chunk larger than the caller's buffer is kept for the next
|
|
36
|
+
`readinto()`."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, path, response: HTTPResponse, chunk_size: int):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self._path = path
|
|
41
|
+
self._response = response
|
|
42
|
+
self._chunks = response.iter_bytes(chunk_size)
|
|
43
|
+
self._pending = b""
|
|
44
|
+
self._offset = 0
|
|
45
|
+
|
|
46
|
+
def readable(self):
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def _next_chunk(self) -> bytes:
|
|
50
|
+
path = self._path
|
|
51
|
+
with translate_errors(path):
|
|
52
|
+
try:
|
|
53
|
+
return next(self._chunks, b"")
|
|
54
|
+
except OSError as error:
|
|
55
|
+
if type(error) is OSError and error.errno == _errno.EIO:
|
|
56
|
+
raise OSError(_errno.EIO, f"{error.strerror} for {path}") from None
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
def readinto(self, buffer):
|
|
60
|
+
view = memoryview(buffer).cast("B")
|
|
61
|
+
if not view:
|
|
62
|
+
return 0
|
|
63
|
+
while self._offset >= len(self._pending):
|
|
64
|
+
chunk = self._next_chunk()
|
|
65
|
+
if not chunk:
|
|
66
|
+
return 0
|
|
67
|
+
self._pending, self._offset = chunk, 0
|
|
68
|
+
count = min(len(view), len(self._pending) - self._offset)
|
|
69
|
+
view[:count] = self._pending[self._offset : self._offset + count]
|
|
70
|
+
self._offset += count
|
|
71
|
+
return count
|
|
72
|
+
|
|
73
|
+
def close(self):
|
|
74
|
+
if not self.closed:
|
|
75
|
+
try:
|
|
76
|
+
self._response.close()
|
|
77
|
+
finally:
|
|
78
|
+
super().close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _response_reader(path, response: HTTPResponse, buffering: int):
|
|
82
|
+
buffer_size = _io.DEFAULT_BUFFER_SIZE if buffering < 0 else buffering
|
|
83
|
+
raw = _ResponseReader(path, response, buffer_size or _io.DEFAULT_BUFFER_SIZE)
|
|
84
|
+
return raw if buffer_size == 0 else _io.BufferedReader(raw, buffer_size)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_EXCLUSIVE_CREATE = {"headers": {"If-None-Match": "*"}}
|
|
88
|
+
"""Request keywords that make a `PUT` create the file only if it is absent
|
|
89
|
+
(RFC 9110 13.1.2)."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _UploadStream(_providers.DiscardOnError, _io.BytesIO):
|
|
93
|
+
"""A write buffer whose `close()` uploads its content through `_upload()`.
|
|
94
|
+
|
|
95
|
+
`_ready` is set by a subclass as the last step of its constructor. A
|
|
96
|
+
stream whose constructor raised stays inert: `IOBase.__del__` still calls
|
|
97
|
+
`close()` on it, and that must neither upload a half-built buffer nor
|
|
98
|
+
fail on state the constructor never reached. A `with` block that raised
|
|
99
|
+
through the stream, or collecting it unclosed, discards it
|
|
100
|
+
(`DiscardOnError`): nothing is sent."""
|
|
101
|
+
|
|
102
|
+
_ready = False
|
|
103
|
+
|
|
104
|
+
def __init__(self, path: "HTTPURIPath"):
|
|
105
|
+
super().__init__()
|
|
106
|
+
self._path = path
|
|
107
|
+
|
|
108
|
+
def _upload(self, data: bytes) -> None:
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
def _holds_uncommitted(self):
|
|
112
|
+
return self._ready
|
|
113
|
+
|
|
114
|
+
def discard(self):
|
|
115
|
+
"""Drop the buffer: `close()` sends nothing."""
|
|
116
|
+
self._ready = False
|
|
117
|
+
if not self.closed:
|
|
118
|
+
self.truncate(0)
|
|
119
|
+
|
|
120
|
+
def close(self):
|
|
121
|
+
if self.closed:
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
if self._ready:
|
|
125
|
+
self._upload(self.getvalue())
|
|
126
|
+
finally:
|
|
127
|
+
# Closed even when the upload fails: a later close() (context
|
|
128
|
+
# manager cleanup, or garbage collection) must not send it again.
|
|
129
|
+
super().close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class HTTPWriteStream(_UploadStream):
|
|
133
|
+
def __init__(self, path: "HTTPURIPath", exclusive: bool = False):
|
|
134
|
+
super().__init__(path)
|
|
135
|
+
# `exclusive` (mode "x"): the upload carries `If-None-Match: *`, and a
|
|
136
|
+
# 412 reply means the file appeared since the caller looked.
|
|
137
|
+
self._exclusive = exclusive
|
|
138
|
+
self._ready = True
|
|
139
|
+
|
|
140
|
+
def _upload(self, data):
|
|
141
|
+
backend = self._path.backend
|
|
142
|
+
extra = _EXCLUSIVE_CREATE if self._exclusive else {}
|
|
143
|
+
with translate_errors(self._path, conflict=FileNotFoundError):
|
|
144
|
+
resp = backend.request(
|
|
145
|
+
backend.write_method,
|
|
146
|
+
self._path.as_uri(),
|
|
147
|
+
data=data,
|
|
148
|
+
stream=True,
|
|
149
|
+
**extra,
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
if self._exclusive and resp.status == 412:
|
|
153
|
+
raise os_error(FileExistsError, self._path)
|
|
154
|
+
raise_for_status(resp)
|
|
155
|
+
finally:
|
|
156
|
+
drop_body(resp)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class HTTPAppendStream(_UploadStream):
|
|
160
|
+
def __init__(self, path: "HTTPURIPath"):
|
|
161
|
+
super().__init__(path)
|
|
162
|
+
if path.backend.append_mode == "rewrite":
|
|
163
|
+
try:
|
|
164
|
+
existing = path.read_bytes()
|
|
165
|
+
except FileNotFoundError:
|
|
166
|
+
existing = b""
|
|
167
|
+
self.write(existing)
|
|
168
|
+
else:
|
|
169
|
+
# "patch" mode: stat the resource to get its size as start offset.
|
|
170
|
+
# Drop a listing-derived hint first: an offset taken from a hint
|
|
171
|
+
# would overwrite the file wherever the listing was wrong.
|
|
172
|
+
path._pop_stat_hint()
|
|
173
|
+
try:
|
|
174
|
+
stat, size_stated = path._head_stat()
|
|
175
|
+
except FileNotFoundError:
|
|
176
|
+
self._start_offset = 0
|
|
177
|
+
self._existed = False
|
|
178
|
+
else:
|
|
179
|
+
if not size_stated:
|
|
180
|
+
raise OSError(
|
|
181
|
+
_errno.EIO,
|
|
182
|
+
f"Cannot append to {path} in patch mode: the HEAD reply "
|
|
183
|
+
"has no Content-Length, so the end of the file is unknown",
|
|
184
|
+
)
|
|
185
|
+
self._start_offset = stat.st_size
|
|
186
|
+
self._existed = True
|
|
187
|
+
self._ready = True
|
|
188
|
+
|
|
189
|
+
def _upload(self, data):
|
|
190
|
+
backend = self._path.backend
|
|
191
|
+
with translate_errors(self._path, conflict=FileNotFoundError):
|
|
192
|
+
if backend.append_mode == "rewrite":
|
|
193
|
+
resp = backend.request(
|
|
194
|
+
backend.write_method, self._path.as_uri(), data=data, stream=True
|
|
195
|
+
)
|
|
196
|
+
_accept(resp)
|
|
197
|
+
else:
|
|
198
|
+
# "patch" mode: send only new content via Content-Range
|
|
199
|
+
if not data:
|
|
200
|
+
# `bytes N-(N-1)/*` is not a valid range: there is
|
|
201
|
+
# nothing to append, only a missing file to create.
|
|
202
|
+
if not self._existed:
|
|
203
|
+
resp = backend.request(
|
|
204
|
+
backend.write_method,
|
|
205
|
+
self._path.as_uri(),
|
|
206
|
+
data=b"",
|
|
207
|
+
stream=True,
|
|
208
|
+
)
|
|
209
|
+
_accept(resp)
|
|
210
|
+
return
|
|
211
|
+
start = self._start_offset
|
|
212
|
+
end = start + len(data) - 1
|
|
213
|
+
headers = {"Content-Range": f"bytes {start}-{end}/*"}
|
|
214
|
+
resp = backend.request(
|
|
215
|
+
"PATCH",
|
|
216
|
+
self._path.as_uri(),
|
|
217
|
+
data=data,
|
|
218
|
+
headers=headers,
|
|
219
|
+
stream=True,
|
|
220
|
+
)
|
|
221
|
+
_accept(resp)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _accept(resp: HTTPResponse) -> None:
|
|
225
|
+
"""Raise for an error status of `resp`, a reply whose body is not used:
|
|
226
|
+
the body is dropped (see `drop_body`) whichever way this goes."""
|
|
227
|
+
try:
|
|
228
|
+
raise_for_status(resp)
|
|
229
|
+
finally:
|
|
230
|
+
drop_body(resp)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""HTTP and WebDAV date headers as epoch seconds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar as _calendar
|
|
6
|
+
import time as _time
|
|
7
|
+
import typing as _ty
|
|
8
|
+
from email.utils import parsedate_tz as _parsedate_tz
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parsedate(date: _ty.Union[str, _time.struct_time, tuple, int, float, None]):
|
|
12
|
+
"""Convert a date to UTC epoch seconds, independent of the host timezone.
|
|
13
|
+
|
|
14
|
+
- `str` (RFC 1123, RFC 850 or asctime, as in `Last-Modified`): parsed
|
|
15
|
+
with `email.utils.parsedate_tz`, and its zone offset applied. A string
|
|
16
|
+
with no zone is read as UTC -- HTTP dates are GMT by definition.
|
|
17
|
+
- `int`/`float`: already epoch seconds, returned unchanged.
|
|
18
|
+
- `struct_time`/tuple: read as UTC (`calendar.timegm`), minus its
|
|
19
|
+
`tm_gmtoff` (or a `parsedate_tz`-style 10th element) when one is set.
|
|
20
|
+
- `None` or an unparseable string: `0`. A string naming a date that does
|
|
21
|
+
not exist ("31 Feb") is not refused: it rolls over to the next month.
|
|
22
|
+
|
|
23
|
+
Nothing else is accepted: `bytes`, a `datetime` and a tuple of fewer than
|
|
24
|
+
six items raise `ValueError` or `TypeError` instead of returning `0`.
|
|
25
|
+
|
|
26
|
+
Local time (`time.mktime`) is never involved: it shifted every HTTP/DAV
|
|
27
|
+
`st_mtime` by the host's UTC offset and overflowed on Windows for dates
|
|
28
|
+
near the epoch.
|
|
29
|
+
"""
|
|
30
|
+
# Missing/unparseable dates yield epoch 0, not "now" -- a caller with no
|
|
31
|
+
# Last-Modified header shouldn't have that read as "just modified" and
|
|
32
|
+
# poison checksum/sync freshness comparisons.
|
|
33
|
+
if date is None:
|
|
34
|
+
return 0
|
|
35
|
+
if isinstance(date, (int, float)):
|
|
36
|
+
return date
|
|
37
|
+
if isinstance(date, str):
|
|
38
|
+
try:
|
|
39
|
+
parsed = _parsedate_tz(date)
|
|
40
|
+
if parsed is None:
|
|
41
|
+
return 0
|
|
42
|
+
return _calendar.timegm(parsed[:6]) - (parsed[9] or 0)
|
|
43
|
+
except (TypeError, ValueError, IndexError, OverflowError):
|
|
44
|
+
return 0
|
|
45
|
+
offset = getattr(date, "tm_gmtoff", None)
|
|
46
|
+
if offset is None and len(date) > 9:
|
|
47
|
+
offset = date[9]
|
|
48
|
+
return _calendar.timegm(tuple(date)[:6]) - (offset or 0)
|