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,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,6 @@
1
+ """The module-level setting of the HTTP schemes. Code reads it as an attribute
2
+ of this module, so assigning it applies at once."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathapi.http import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT
@@ -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)