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
uripath/schemes/_file.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os as _os
|
|
4
|
+
|
|
5
|
+
from pathapi import FsPathLike
|
|
6
|
+
from pathapi import LocalPath as _Local
|
|
7
|
+
from ..source import Source
|
|
8
|
+
from ..source import is_drive
|
|
9
|
+
from ._delegate import DelegatingURIPath
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileURIPath(DelegatingURIPath):
|
|
13
|
+
"""`file:` scheme: a `URIPath` wrapping a `LocalPath` (`filepath`),
|
|
14
|
+
delegating all I/O to it."""
|
|
15
|
+
|
|
16
|
+
__SCHEMES = ("file",)
|
|
17
|
+
__slots__ = ()
|
|
18
|
+
_DELEGATED = ("_scandir", "_chown", "_utime", "unlink", "rmdir", "chmod")
|
|
19
|
+
_RELABEL = False
|
|
20
|
+
|
|
21
|
+
def _make_core(self):
|
|
22
|
+
return _Local(self.__fspath__())
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def filepath(self):
|
|
26
|
+
return self._core_path
|
|
27
|
+
|
|
28
|
+
def _unc_share(self) -> "str | None":
|
|
29
|
+
"""The share of a UNC path ("/share" under a named host), else None.
|
|
30
|
+
On Windows a named host is a server and `//server/share` is the anchor
|
|
31
|
+
of everything below it. Only the text is looked at; nothing is
|
|
32
|
+
resolved."""
|
|
33
|
+
host = self.source.host
|
|
34
|
+
segments = self.segments
|
|
35
|
+
if (
|
|
36
|
+
_os.name == "nt"
|
|
37
|
+
and host
|
|
38
|
+
and not (isinstance(host, str) and host.lower() == "localhost")
|
|
39
|
+
and segments[:1] == ("",)
|
|
40
|
+
and len(segments) > 1
|
|
41
|
+
and segments[1]
|
|
42
|
+
):
|
|
43
|
+
return "/" + segments[1]
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def parent(self):
|
|
48
|
+
if _os.name == "nt" and is_drive(self.path.removeprefix("/")):
|
|
49
|
+
# A bare drive is its own parent, as in `PureWindowsPath("C:")`.
|
|
50
|
+
return self
|
|
51
|
+
share = self._unc_share()
|
|
52
|
+
if share is not None:
|
|
53
|
+
if self.path.rstrip("/") == share:
|
|
54
|
+
return self
|
|
55
|
+
parent = super().parent
|
|
56
|
+
if parent.path == share:
|
|
57
|
+
# The parent of a top-level name in a share is the share root.
|
|
58
|
+
return parent.with_path(share + "/")
|
|
59
|
+
return parent
|
|
60
|
+
parent = super().parent
|
|
61
|
+
path = parent.path
|
|
62
|
+
if _os.name == "nt" and is_drive(path.removeprefix("/")):
|
|
63
|
+
# "C:" alone is drive-RELATIVE (the current directory on C:); the
|
|
64
|
+
# parent of a top-level "C:/Windows" is the drive root "C:/".
|
|
65
|
+
return parent.with_path(path + "/")
|
|
66
|
+
return parent
|
|
67
|
+
|
|
68
|
+
def _anchor_segments(self):
|
|
69
|
+
# The drive is the anchor of "C:/a/b" (and "/C:/a/b" under a host),
|
|
70
|
+
# so `parents` ends at the drive root "C:/" like `parent` does; the
|
|
71
|
+
# share is the anchor of a UNC path.
|
|
72
|
+
segments = self.segments
|
|
73
|
+
if _os.name == "nt":
|
|
74
|
+
if segments[:1] and is_drive(segments[0]):
|
|
75
|
+
return segments[:1]
|
|
76
|
+
if segments[:1] == ("",) and len(segments) > 1 and is_drive(segments[1]):
|
|
77
|
+
return segments[:2]
|
|
78
|
+
if self._unc_share() is not None:
|
|
79
|
+
return segments[:2]
|
|
80
|
+
return super()._anchor_segments()
|
|
81
|
+
|
|
82
|
+
def with_segments(self, *segments):
|
|
83
|
+
# A Windows path string separates its names with backslashes.
|
|
84
|
+
if _os.name == "nt":
|
|
85
|
+
segments = tuple(
|
|
86
|
+
item.replace("\\", "/") if isinstance(item, str) else item
|
|
87
|
+
for item in segments
|
|
88
|
+
)
|
|
89
|
+
return super().with_segments(*segments)
|
|
90
|
+
|
|
91
|
+
def _join_decoded(self, key: str):
|
|
92
|
+
# A Windows path string separates its names with backslashes;
|
|
93
|
+
# elsewhere a backslash is an ordinary character of a file name.
|
|
94
|
+
if _os.name == "nt" and "\\" in key:
|
|
95
|
+
key = key.replace("\\", "/")
|
|
96
|
+
return super()._join_decoded(key)
|
|
97
|
+
|
|
98
|
+
def _is_absolute_decoded(self, path: str) -> bool:
|
|
99
|
+
"""A drive path restarts a join, as `PureWindowsPath` does: joining
|
|
100
|
+
"C:/Temp/x" onto "D:/proj" gives "C:/Temp/x", not a child of it.
|
|
101
|
+
`is_absolute()` already answers True for such a path, so the join
|
|
102
|
+
has to agree with it."""
|
|
103
|
+
if _os.name == "nt":
|
|
104
|
+
drive, sep, _ = path.partition("/")
|
|
105
|
+
if is_drive(drive):
|
|
106
|
+
return bool(sep)
|
|
107
|
+
return super()._is_absolute_decoded(path)
|
|
108
|
+
|
|
109
|
+
def is_absolute(self):
|
|
110
|
+
"""Also True for a Windows drive path ("C:/x"), which `_init()`
|
|
111
|
+
stores without the "/" a URI path gets in front of it. "C:" alone is
|
|
112
|
+
drive-relative, as for `PureWindowsPath`."""
|
|
113
|
+
path = self.path
|
|
114
|
+
if _os.name == "nt":
|
|
115
|
+
drive, sep, _ = path.partition("/")
|
|
116
|
+
if is_drive(drive):
|
|
117
|
+
return bool(sep)
|
|
118
|
+
return super().is_absolute()
|
|
119
|
+
|
|
120
|
+
def _init(
|
|
121
|
+
self,
|
|
122
|
+
source: Source,
|
|
123
|
+
path: str,
|
|
124
|
+
query: str,
|
|
125
|
+
fragment: str,
|
|
126
|
+
/,
|
|
127
|
+
**kwargs,
|
|
128
|
+
):
|
|
129
|
+
# With a host ("file://localhost/C:/x") the "/" before the drive
|
|
130
|
+
# stays: a path under an authority must start with "/", and without
|
|
131
|
+
# it str()/repr()/hash() raised ValueError. `__fspath__` drops it.
|
|
132
|
+
if _os.name == "nt" and path and path[0] == "/" and not source.host:
|
|
133
|
+
root, *_ = path[1:].split("/", maxsplit=1)
|
|
134
|
+
if root and root[-1] == ":":
|
|
135
|
+
path = path.removeprefix("/")
|
|
136
|
+
super()._init(source, path, query, fragment, **kwargs)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def _format_parsed_parts(cls, source, path, query, fragment, /, sanitize=True):
|
|
140
|
+
if _os.name == "nt" and is_drive(path.partition("/")[0]):
|
|
141
|
+
# Render "C:/x" as "file:/C:/x", the spelling `URI` itself
|
|
142
|
+
# parses it to, so `URI(p) == URIPath(p)` for a local path.
|
|
143
|
+
path = "/" + path
|
|
144
|
+
return super()._format_parsed_parts(
|
|
145
|
+
source, path, query, fragment, sanitize=sanitize
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _listdir(self):
|
|
149
|
+
yield from _os.listdir(self.filepath)
|
|
150
|
+
|
|
151
|
+
def is_junction(self) -> bool:
|
|
152
|
+
return self.filepath.is_junction()
|
|
153
|
+
|
|
154
|
+
def is_mount(self) -> bool:
|
|
155
|
+
return self.filepath.is_mount()
|
|
156
|
+
|
|
157
|
+
def stat(self, *, follow_symlinks=True):
|
|
158
|
+
# LocalPath.stat() itself shims the 3.10+-only follow_symlinks= kwarg
|
|
159
|
+
# for pathlib.Path (see fspath.py), so just delegate.
|
|
160
|
+
return self.filepath.stat(follow_symlinks=follow_symlinks)
|
|
161
|
+
|
|
162
|
+
def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None):
|
|
163
|
+
return self.filepath.open(mode, buffering, encoding, errors, newline)
|
|
164
|
+
|
|
165
|
+
def mkdir(self, mode=511, parents=False, exist_ok=False):
|
|
166
|
+
return self.filepath.mkdir(mode, parents, exist_ok)
|
|
167
|
+
|
|
168
|
+
def touch(self, mode=None, exist_ok=True):
|
|
169
|
+
# pathlib's touch: the mode goes through os.open(), so the umask
|
|
170
|
+
# applies, and an existing file's mtime is bumped. The generic
|
|
171
|
+
# Path.touch can do neither.
|
|
172
|
+
if mode is None:
|
|
173
|
+
return self.filepath.touch(exist_ok=exist_ok)
|
|
174
|
+
return self.filepath.touch(mode, exist_ok)
|
|
175
|
+
|
|
176
|
+
def rename(self, target: FsPathLike | str):
|
|
177
|
+
# Through `_rename_target()` like every scheme: a relative str is a sibling rename
|
|
178
|
+
# (not relative to the process cwd), a local path is accepted, and a remote URI
|
|
179
|
+
# raises NotImplementedError so move() copies.
|
|
180
|
+
dest = self._rename_target(target)
|
|
181
|
+
if not isinstance(dest, FileURIPath):
|
|
182
|
+
dest = self._from_parsed_parts(
|
|
183
|
+
dest.source or self.source, dest.path, None, None
|
|
184
|
+
)
|
|
185
|
+
self.filepath.rename(dest.filepath)
|
|
186
|
+
return dest
|
uripath/schemes/_ftp.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging as _logging
|
|
4
|
+
import threading as _thread
|
|
5
|
+
import typing as _ty
|
|
6
|
+
import weakref as _weakref
|
|
7
|
+
|
|
8
|
+
import netimps as _netimps
|
|
9
|
+
|
|
10
|
+
from pathapi.ftp import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT
|
|
11
|
+
from pathapi.ftp import BaseFTPBackend, FTPBackend, FTPPath
|
|
12
|
+
from pathapi.providers import notimplemented as _notimplemented
|
|
13
|
+
from .._uri import URI
|
|
14
|
+
from ..source import Source, fold_host
|
|
15
|
+
from ._delegate import DelegatingURIPath
|
|
16
|
+
|
|
17
|
+
if _ty.TYPE_CHECKING:
|
|
18
|
+
import ssl as _ssl
|
|
19
|
+
|
|
20
|
+
_log = _logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseFTPURIBackend(object):
|
|
24
|
+
"""Protocol for obtaining a connected+logged-in FTP client (or
|
|
25
|
+
its TLS variant) for a `Source`. Subclass this to plug in custom connection
|
|
26
|
+
handling (e.g. tests mock it directly, no real server);
|
|
27
|
+
`FTPURIBackend` is the real implementation. A path to each endpoint
|
|
28
|
+
(`Source` and `tls`) runs on a core `pathapi.ftp.BaseFTPBackend`
|
|
29
|
+
that asks `client()` for its connections.
|
|
30
|
+
|
|
31
|
+
A context manager: leaving the block calls `close()`. One backend may be
|
|
32
|
+
shared between threads; each thread keeps its own connection per server."""
|
|
33
|
+
|
|
34
|
+
# Weakly referenceable so `URIPath` can record which backends a path
|
|
35
|
+
# derived for itself (see `URIPath._supplied_backend()`).
|
|
36
|
+
__slots__ = ("__weakref__",)
|
|
37
|
+
|
|
38
|
+
@_notimplemented
|
|
39
|
+
def client(self, source: Source, tls: bool) -> "_ty.Any":
|
|
40
|
+
"""The connected, logged-in client for `source`; `tls` asks for `ftps:`."""
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
"""Closes every control connection the paths of this backend hold,
|
|
44
|
+
whichever thread opened it; one a command is running on closes when
|
|
45
|
+
that command ends. Harmless when called again; a later use connects
|
|
46
|
+
afresh."""
|
|
47
|
+
for endpoint in _endpoints_of(self):
|
|
48
|
+
endpoint.close()
|
|
49
|
+
|
|
50
|
+
def __enter__(self):
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, *exc_info) -> None:
|
|
54
|
+
self.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FTPURIBackend(BaseFTPURIBackend):
|
|
58
|
+
"""Connects via the stdlib FTP client (`ftp:`) or its TLS variant
|
|
59
|
+
(`ftps:`, with `PROT P` for an encrypted data channel too).
|
|
60
|
+
|
|
61
|
+
`timeout` (seconds, default `DEFAULT_TIMEOUT` = 30) bounds connect,
|
|
62
|
+
every reply and every transfer read; `None` blocks forever.
|
|
63
|
+
|
|
64
|
+
`ftps:` verifies the server certificate and host name by default
|
|
65
|
+
(`ssl.create_default_context()`), before `USER`/`PASS` are sent. To
|
|
66
|
+
trust a private CA or a self-signed certificate, pass your own
|
|
67
|
+
`ssl_context` (e.g. `ssl.create_default_context(cafile=...)`). To turn
|
|
68
|
+
verification off entirely -- accepting any certificate, so anyone on
|
|
69
|
+
the network path can read the password -- pass `verify=False`.
|
|
70
|
+
`ssl_context` wins over `verify` when both are given. Data connections
|
|
71
|
+
reuse the control connection's TLS session."""
|
|
72
|
+
|
|
73
|
+
__slots__ = ("timeout", "ssl_context", "verify")
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
timeout: "float | None" = DEFAULT_TIMEOUT,
|
|
78
|
+
ssl_context: "_ssl.SSLContext | None" = None,
|
|
79
|
+
verify: bool = True,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.timeout = timeout
|
|
82
|
+
self.ssl_context = ssl_context
|
|
83
|
+
self.verify = verify
|
|
84
|
+
if not verify and ssl_context is None:
|
|
85
|
+
_log.warning(
|
|
86
|
+
"FTPURIBackend(verify=False): TLS certificates are not verified"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def client(self, source: Source, tls: bool):
|
|
90
|
+
user, password = source.parsed_userinfo()
|
|
91
|
+
return FTPBackend(
|
|
92
|
+
str(source.host),
|
|
93
|
+
source.port or _netimps.get_default_port("ftp"),
|
|
94
|
+
user=user,
|
|
95
|
+
password=password,
|
|
96
|
+
tls=tls,
|
|
97
|
+
timeout=self.timeout,
|
|
98
|
+
ssl_context=self.ssl_context,
|
|
99
|
+
verify=self.verify,
|
|
100
|
+
).client()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_DEFAULT_BACKEND = FTPURIBackend()
|
|
104
|
+
"""The backend every `FTPURIPath` built without `backend=` shares, so separately
|
|
105
|
+
constructed paths to one server reuse one connection per thread instead of
|
|
106
|
+
opening (and keeping) one each."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _EndpointBackend(BaseFTPBackend):
|
|
110
|
+
"""The core backend of one endpoint of a `BaseFTPURIBackend`: its connections
|
|
111
|
+
are the ones the owner's `client(source, tls)` opens, and its endpoint is
|
|
112
|
+
the `Source`, so paths to one server through one owner share them."""
|
|
113
|
+
|
|
114
|
+
__slots__ = ("owner", "source", "tls", "endpoint")
|
|
115
|
+
|
|
116
|
+
def __init__(self, owner: BaseFTPURIBackend, source: Source, tls: bool):
|
|
117
|
+
self.owner = owner
|
|
118
|
+
self.source = source
|
|
119
|
+
self.tls = tls
|
|
120
|
+
# The key of the connections: `ftp://NAS/` and `ftp://nas/` are one server.
|
|
121
|
+
self.endpoint = _endpoint(source)
|
|
122
|
+
|
|
123
|
+
def client(self):
|
|
124
|
+
return self.owner.client(self.source, self.tls)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _endpoint(source: Source) -> Source:
|
|
128
|
+
"""`source` with its host name folded, to key what is shared per server."""
|
|
129
|
+
return source._replace(host=fold_host(source.host))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_ENDPOINTS: "_weakref.WeakValueDictionary[tuple, _EndpointBackend]" = (
|
|
133
|
+
_weakref.WeakValueDictionary()
|
|
134
|
+
)
|
|
135
|
+
_ENDPOINTS_LOCK = _thread.Lock()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _endpoint_backend(owner: BaseFTPURIBackend, source: Source, tls: bool):
|
|
139
|
+
"""The one core backend for `owner`, `source` and `tls`, while any path or
|
|
140
|
+
cached connection holds it. The owner is held by the entry, so its `id`
|
|
141
|
+
names it for as long as the entry lives."""
|
|
142
|
+
key = (id(owner), _endpoint(source), tls)
|
|
143
|
+
with _ENDPOINTS_LOCK:
|
|
144
|
+
found = _ENDPOINTS.get(key)
|
|
145
|
+
if found is None:
|
|
146
|
+
found = _ENDPOINTS[key] = _EndpointBackend(owner, source, tls)
|
|
147
|
+
return found
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _endpoints_of(owner: BaseFTPURIBackend) -> "list[_EndpointBackend]":
|
|
151
|
+
"""The core backends that `owner` has made and that something still holds."""
|
|
152
|
+
with _ENDPOINTS_LOCK:
|
|
153
|
+
return [found for key, found in list(_ENDPOINTS.items()) if key[0] == id(owner)]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class FTPURIPath(DelegatingURIPath):
|
|
157
|
+
"""`ftp:`/`ftps:` scheme: full read/write access via the stdlib FTP client,
|
|
158
|
+
done by a `pathapi.ftp.FTPPath` on the connections of `backend`
|
|
159
|
+
(a thread-keyed LRU cache; a thread's connections close when it ends). A
|
|
160
|
+
listing prefers MLSD (RFC 3659 -- type/size/modify facts in one round
|
|
161
|
+
trip) and a stat MLST of the entry itself where FEAT lists it, else its
|
|
162
|
+
parent's MLSD; servers that don't support them fall back to NLST for
|
|
163
|
+
listing (names only) and SIZE/CWD for stat (no portable "not found vs. is
|
|
164
|
+
a directory" distinction in that path)."""
|
|
165
|
+
|
|
166
|
+
__SCHEMES = ("ftp", "ftps")
|
|
167
|
+
__slots__ = ()
|
|
168
|
+
_DELEGATED = (
|
|
169
|
+
"_scandir",
|
|
170
|
+
"_listdir",
|
|
171
|
+
"_open",
|
|
172
|
+
"_mkdir",
|
|
173
|
+
"unlink",
|
|
174
|
+
"rmdir",
|
|
175
|
+
"chmod",
|
|
176
|
+
"_utime",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if _ty.TYPE_CHECKING:
|
|
180
|
+
backend: BaseFTPURIBackend
|
|
181
|
+
|
|
182
|
+
def _initbackend(self):
|
|
183
|
+
return _DEFAULT_BACKEND
|
|
184
|
+
|
|
185
|
+
def _make_core(self) -> FTPPath:
|
|
186
|
+
source = self.source
|
|
187
|
+
backend = _endpoint_backend(self.backend, source, source.scheme == "ftps")
|
|
188
|
+
return FTPPath(self.path or "/", backend=backend)
|
|
189
|
+
|
|
190
|
+
def rename(self, target: "FTPURIPath | URI | str"):
|
|
191
|
+
# A plain str target is a sibling rename (relative to self's
|
|
192
|
+
# parent), matching sftp.py's rename() semantics.
|
|
193
|
+
target = self._rename_target(target)
|
|
194
|
+
destination = self.with_path(target.path)
|
|
195
|
+
core = self._core_path
|
|
196
|
+
moved = core.with_segments(target.path or "/")
|
|
197
|
+
with self._delegated((str(moved), str(destination))):
|
|
198
|
+
core.rename(moved)
|
|
199
|
+
# pathlib returns the new path.
|
|
200
|
+
return destination
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as _ty
|
|
4
|
+
|
|
5
|
+
from pathapi.http import HTTPClient, HTTPResponse, split_userinfo
|
|
6
|
+
|
|
7
|
+
from ..._version import __version__
|
|
8
|
+
from .._path import DerivedBackend
|
|
9
|
+
from . import _settings
|
|
10
|
+
|
|
11
|
+
if _ty.TYPE_CHECKING:
|
|
12
|
+
from ._path import HTTPURIPath
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_UNSET = object()
|
|
16
|
+
"""The `timeout` of a call that did not give one: the backend's own."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HTTPBackend(HTTPClient):
|
|
20
|
+
"""The transport and the settings shared by an `HTTPURIPath` tree (see
|
|
21
|
+
`with_transport()` and `with_session()`): a `pathapi.http.HTTPClient`
|
|
22
|
+
that takes a path or a URL for its address and adds `write_method` and
|
|
23
|
+
`append_mode`.
|
|
24
|
+
|
|
25
|
+
`transport` is any object with a `send()` method as
|
|
26
|
+
`pathapi.http.HTTPTransport.send()`; without one the backend builds the
|
|
27
|
+
built-in `HTTPTransport` on first use and closes it with `close()` or at the
|
|
28
|
+
end of a `with` block. A transport it was given stays the caller's.
|
|
29
|
+
`headers` are sent with every request, request-specific headers merged
|
|
30
|
+
over them by name; every request gets `timeout=(10, 60)` seconds unless
|
|
31
|
+
`timeout` here or in the call says otherwise (`None` waits without limit).
|
|
32
|
+
URL userinfo (`http://user:password@host/`) is never sent inside the
|
|
33
|
+
request URL: it is stripped and sent as an `Authorization: Basic` header
|
|
34
|
+
unless `headers` or the call name one.
|
|
35
|
+
|
|
36
|
+
The backend, through `HTTPClient`, follows redirects. `GET`, `HEAD` and
|
|
37
|
+
`OPTIONS` follow up to 30; a redirect that changes the scheme, host or
|
|
38
|
+
port drops `Authorization` (and nothing else). Any other method
|
|
39
|
+
(`PROPFIND`, `PUT`, `PATCH`, `DELETE`, `MKCOL`, `MOVE`, a `write_method`
|
|
40
|
+
such as `POST`) is sent without following: a 307 or 308 (for `PROPFIND` also
|
|
41
|
+
a 301, 302 or 303) to the same scheme, host and port is sent once more
|
|
42
|
+
with the same method and body, and any other 3xx, a redirect to another
|
|
43
|
+
origin or a second redirect raises `OSError(EIO)`."""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("write_method", "append_mode")
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
transport=None,
|
|
50
|
+
*,
|
|
51
|
+
headers=None,
|
|
52
|
+
timeout=_settings.DEFAULT_TIMEOUT,
|
|
53
|
+
write_method: str = "PUT",
|
|
54
|
+
append_mode: str = "rewrite",
|
|
55
|
+
):
|
|
56
|
+
super().__init__(
|
|
57
|
+
transport,
|
|
58
|
+
headers=headers,
|
|
59
|
+
timeout=timeout,
|
|
60
|
+
user_agent=f"uripath/{__version__}",
|
|
61
|
+
)
|
|
62
|
+
self.write_method = write_method
|
|
63
|
+
self.append_mode = append_mode
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"{type(self).__name__}(write_method={self.write_method!r})"
|
|
67
|
+
|
|
68
|
+
def request(
|
|
69
|
+
self,
|
|
70
|
+
method,
|
|
71
|
+
uri: "HTTPURIPath|str",
|
|
72
|
+
*,
|
|
73
|
+
data=None,
|
|
74
|
+
headers=None,
|
|
75
|
+
stream: bool = False,
|
|
76
|
+
allow_redirects=None,
|
|
77
|
+
timeout=_UNSET,
|
|
78
|
+
) -> HTTPResponse:
|
|
79
|
+
"""Send `method` to `uri` and return the final response. A `data`
|
|
80
|
+
that is not `bytes` (a file, an iterable) cannot be sent twice, so it
|
|
81
|
+
is not re-sent after a redirect or a dropped connection.
|
|
82
|
+
`allow_redirects=False` returns the first answer of a `GET`, `HEAD`
|
|
83
|
+
or `OPTIONS` as it is. `ValueError` for a URL or header with a control
|
|
84
|
+
character, before anything is sent.
|
|
85
|
+
|
|
86
|
+
With `stream=False` the body is read before this returns, and a decoded
|
|
87
|
+
body over `pathapi.http.MAX_REPLY_BYTES` is `OSError(EFBIG)`; with
|
|
88
|
+
`stream=True` the caller reads it, and closes the response."""
|
|
89
|
+
url, auth = split_userinfo(uri if isinstance(uri, str) else uri.as_uri(False))
|
|
90
|
+
return super().request(
|
|
91
|
+
method,
|
|
92
|
+
url,
|
|
93
|
+
data=data,
|
|
94
|
+
headers=headers,
|
|
95
|
+
stream=stream,
|
|
96
|
+
allow_redirects=allow_redirects,
|
|
97
|
+
timeout=self.timeout if timeout is _UNSET else timeout,
|
|
98
|
+
auth=auth,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def redirect(
|
|
102
|
+
self,
|
|
103
|
+
method,
|
|
104
|
+
uri: "HTTPURIPath|str",
|
|
105
|
+
resp: HTTPResponse,
|
|
106
|
+
*,
|
|
107
|
+
headers=None,
|
|
108
|
+
stream: bool = False,
|
|
109
|
+
timeout=_UNSET,
|
|
110
|
+
) -> HTTPResponse:
|
|
111
|
+
"""Send `method` to where `resp`, the answer to a request for `uri`,
|
|
112
|
+
redirects to, and follow what it redirects to in turn. The request
|
|
113
|
+
carries what `uri` is sent with, except that `Authorization` is left
|
|
114
|
+
off whenever it goes to another scheme, host or port than `uri`'s.
|
|
115
|
+
A redirect with no usable `Location` is `OSError(EIO)`."""
|
|
116
|
+
url, auth = split_userinfo(uri if isinstance(uri, str) else uri.as_uri(False))
|
|
117
|
+
return super().redirect(
|
|
118
|
+
method,
|
|
119
|
+
url,
|
|
120
|
+
resp,
|
|
121
|
+
headers=headers,
|
|
122
|
+
stream=stream,
|
|
123
|
+
timeout=self.timeout if timeout is _UNSET else timeout,
|
|
124
|
+
auth=auth,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _DerivedHTTPBackend(HTTPBackend, DerivedBackend):
|
|
129
|
+
"""What `HTTPURIPath._initbackend()` returns: the class itself says the path
|
|
130
|
+
derived this backend for itself (see `URIPath._supplied_backend()`)."""
|
|
131
|
+
|
|
132
|
+
__slots__ = ()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Headers the HTTP schemes send for their own purposes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_IDENTITY_ENCODING = {"Accept-Encoding": "identity"}
|
|
6
|
+
"""Sent on content GETs and `stat()` probes: a transport advertises
|
|
7
|
+
gzip/deflate by default, and a stored blob must not come back as its encoded
|
|
8
|
+
form, while `Content-Length` would count the encoded bytes."""
|