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,134 @@
|
|
|
1
|
+
"""Which class a scheme name belongs to: the classes that registered, the
|
|
2
|
+
built-in table and the `uripath.schemes` entry points."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import threading as _threading
|
|
7
|
+
import typing as _ty
|
|
8
|
+
|
|
9
|
+
if _ty.TYPE_CHECKING:
|
|
10
|
+
from ._path import URIPath
|
|
11
|
+
|
|
12
|
+
#: `(entry_points function, {scheme: entry point})` for the
|
|
13
|
+
#: `uripath.schemes` group, read once per lookup function; a scheme no
|
|
14
|
+
#: plugin declares is a dictionary miss and adds nothing to it.
|
|
15
|
+
_ENTRY_POINTS: "tuple[object, dict[str, object]] | None" = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
#: Schemes whose entry point loaded without registering them: loading it
|
|
19
|
+
#: again cannot help.
|
|
20
|
+
_ENTRY_POINTS_LOADED: "set[str]" = set()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
#: Built-in scheme -> the module that defines its class. A name listed here is
|
|
24
|
+
#: resolved from this table before any entry point is consulted.
|
|
25
|
+
_BUILTIN_SCHEMES = {
|
|
26
|
+
"file": "uripath.schemes._file",
|
|
27
|
+
"data": "uripath.schemes._data",
|
|
28
|
+
"zip": "uripath.schemes._archive",
|
|
29
|
+
"tar": "uripath.schemes._archive",
|
|
30
|
+
"archive": "uripath.schemes._archive",
|
|
31
|
+
"archive+zip": "uripath.schemes._archive",
|
|
32
|
+
"archive+tar": "uripath.schemes._archive",
|
|
33
|
+
"ftp": "uripath.schemes._ftp",
|
|
34
|
+
"ftps": "uripath.schemes._ftp",
|
|
35
|
+
"http": "uripath.schemes._http._path",
|
|
36
|
+
"https": "uripath.schemes._http._path",
|
|
37
|
+
"dav": "uripath.schemes._dav",
|
|
38
|
+
"davs": "uripath.schemes._dav",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
#: Scheme -> the class that registered it, filled when the class is defined.
|
|
43
|
+
_CLAIMS: "dict[str, type[URIPath]]" = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: Schemes a class took with `replace=True`; a built-in class defined after
|
|
47
|
+
#: one of them leaves it with the replacement.
|
|
48
|
+
_REPLACED: "set[str]" = set()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
#: Makes checking a claim and recording it one step.
|
|
52
|
+
_CLAIMS_LOCK = _threading.Lock()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _forget_entry_points() -> None:
|
|
56
|
+
global _ENTRY_POINTS
|
|
57
|
+
_ENTRY_POINTS = None
|
|
58
|
+
_ENTRY_POINTS_LOADED.clear()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _declared_entry_points() -> "dict[str, object]":
|
|
62
|
+
"""The `uripath.schemes` entry points by scheme name, the first
|
|
63
|
+
declaration of a name winning. Scanning every distribution cost 13-29 ms
|
|
64
|
+
per construction with an unregistered scheme (`C:/x` on Windows
|
|
65
|
+
included), so the group is read once per lookup function and dropped when
|
|
66
|
+
a `URIPath` subclass is defined (`_forget_entry_points`)."""
|
|
67
|
+
global _ENTRY_POINTS
|
|
68
|
+
import importlib.metadata as _metadata
|
|
69
|
+
|
|
70
|
+
cached = _ENTRY_POINTS
|
|
71
|
+
if cached is None or cached[0] is not _metadata.entry_points:
|
|
72
|
+
try:
|
|
73
|
+
eps = _metadata.entry_points(group="uripath.schemes")
|
|
74
|
+
except TypeError:
|
|
75
|
+
# Python 3.9 fallback
|
|
76
|
+
eps = _metadata.entry_points().get("uripath.schemes", ())
|
|
77
|
+
declared: "dict[str, object]" = {}
|
|
78
|
+
for ep in eps:
|
|
79
|
+
declared.setdefault(ep.name, ep)
|
|
80
|
+
cached = _ENTRY_POINTS = (_metadata.entry_points, declared)
|
|
81
|
+
return cached[1]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_builtin_class(scheme: str, cls: type) -> bool:
|
|
85
|
+
"""Whether `cls` is defined in the module (or package) the built-in
|
|
86
|
+
table names for `scheme`."""
|
|
87
|
+
module = _BUILTIN_SCHEMES.get(scheme)
|
|
88
|
+
return module is not None and (
|
|
89
|
+
cls.__module__ == module or cls.__module__.startswith(module + ".")
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _same_definition(a: type, b: type) -> bool:
|
|
94
|
+
"""Whether two classes come from one `class` statement run twice (a
|
|
95
|
+
reload, a function body called again): same module, same qualified name."""
|
|
96
|
+
return a.__module__ == b.__module__ and a.__qualname__ == b.__qualname__
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _claim_schemes(cls: type, replace: bool) -> None:
|
|
100
|
+
"""Register the schemes `cls` lists in its own `__SCHEMES`.
|
|
101
|
+
|
|
102
|
+
A name a different class holds, or a built-in name whose class is not the
|
|
103
|
+
built-in one, is a `ValueError` unless `replace` is true; the same
|
|
104
|
+
definition run again takes its names over. Nothing is registered when any
|
|
105
|
+
name is refused.
|
|
106
|
+
"""
|
|
107
|
+
names = tuple(dict.fromkeys(cls.__dict__.get(f"_{cls.__name__}__SCHEMES", ())))
|
|
108
|
+
with _CLAIMS_LOCK:
|
|
109
|
+
taking = []
|
|
110
|
+
for name in names:
|
|
111
|
+
held = _CLAIMS.get(name)
|
|
112
|
+
if replace:
|
|
113
|
+
taking.append(name)
|
|
114
|
+
elif held is None:
|
|
115
|
+
if name in _BUILTIN_SCHEMES and not _is_builtin_class(name, cls):
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"{name!r} is a built-in URI scheme; pass replace=True "
|
|
118
|
+
f"in the class statement of {cls.__qualname__} to replace it"
|
|
119
|
+
)
|
|
120
|
+
taking.append(name)
|
|
121
|
+
elif _same_definition(held, cls):
|
|
122
|
+
taking.append(name)
|
|
123
|
+
elif name in _REPLACED and _is_builtin_class(name, cls):
|
|
124
|
+
continue
|
|
125
|
+
else:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"URI scheme {name!r} is already registered by "
|
|
128
|
+
f"{held.__module__}.{held.__qualname__}; pass replace=True "
|
|
129
|
+
f"in the class statement of {cls.__qualname__} to replace it"
|
|
130
|
+
)
|
|
131
|
+
for name in taking:
|
|
132
|
+
_CLAIMS[name] = cls
|
|
133
|
+
if replace:
|
|
134
|
+
_REPLACED.add(name)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""`Source` (the scheme, userinfo, host and port of a URI) and the helpers that
|
|
2
|
+
read and compose the text of one."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from ._compose import SAFE_PATH, compose_host, compose_uri, percent_encode
|
|
7
|
+
from ._parse import ERRORS, fold_host, is_drive
|
|
8
|
+
from ._source import Source, same_authority
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ERRORS",
|
|
12
|
+
"SAFE_PATH",
|
|
13
|
+
"Source",
|
|
14
|
+
"compose_host",
|
|
15
|
+
"compose_uri",
|
|
16
|
+
"fold_host",
|
|
17
|
+
"is_drive",
|
|
18
|
+
"percent_encode",
|
|
19
|
+
"same_authority",
|
|
20
|
+
]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Composing URI text from decoded components.
|
|
2
|
+
|
|
3
|
+
Direct string assembly instead of `uricompose()`'s per-call re-validation, which
|
|
4
|
+
only matters for untrusted input: these parts come from a parse or a join.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ipaddress as _ip
|
|
10
|
+
import re as _re
|
|
11
|
+
|
|
12
|
+
from .. import _rfc3986
|
|
13
|
+
from ._parse import (
|
|
14
|
+
ERRORS,
|
|
15
|
+
_IPAddress,
|
|
16
|
+
_ipv6_from_literal,
|
|
17
|
+
_parse_port,
|
|
18
|
+
_split_userinfo_text,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_SUB_DELIMS = "!$&'()*+,;="
|
|
22
|
+
_SAFE_USER = _SUB_DELIMS
|
|
23
|
+
_SAFE_USERINFO = _SUB_DELIMS + ":"
|
|
24
|
+
_SAFE_HOST = _SUB_DELIMS
|
|
25
|
+
#: The characters `percent_encode()` leaves as they are in a path.
|
|
26
|
+
SAFE_PATH = _SUB_DELIMS + ":@/"
|
|
27
|
+
_SAFE_PATH = SAFE_PATH
|
|
28
|
+
_SAFE_QUERY = _SUB_DELIMS + ":@/?"
|
|
29
|
+
_SAFE_FRAGMENT = _SAFE_QUERY
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_PERCENT_ESCAPE = _re.compile("(%[0-9A-Fa-f]{2})")
|
|
33
|
+
_SCHEME_RE = _re.compile(r"[A-Za-z][A-Za-z0-9+.\-]*")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _lenient_utf8(text: str) -> bytes:
|
|
37
|
+
"""`text` as UTF-8 where a surrogate that `surrogateescape` cannot carry
|
|
38
|
+
(a lone U+D800-U+DBFF or U+DC00-U+DC7F, which an NTFS name may hold) is
|
|
39
|
+
written the way `surrogatepass` writes it, and an escaped byte
|
|
40
|
+
(U+DC80-U+DCFF) is that byte again."""
|
|
41
|
+
out = bytearray()
|
|
42
|
+
for char in text:
|
|
43
|
+
code = ord(char)
|
|
44
|
+
if 0xDC80 <= code <= 0xDCFF:
|
|
45
|
+
out.append(code - 0xDC00)
|
|
46
|
+
else:
|
|
47
|
+
out += char.encode("utf-8", "surrogatepass")
|
|
48
|
+
return bytes(out)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def percent_encode(text: str, safe: str) -> str:
|
|
52
|
+
"""`text` percent-encoded as UTF-8, keeping the characters of `safe`.
|
|
53
|
+
A decoded path may hold escaped bytes (`ERRORS`) and, from a Windows
|
|
54
|
+
listing, a lone surrogate; neither may make the composer raise."""
|
|
55
|
+
try:
|
|
56
|
+
return _rfc3986.encode(text, safe, errors=ERRORS)
|
|
57
|
+
except UnicodeEncodeError:
|
|
58
|
+
return _rfc3986.encode(_lenient_utf8(text), safe)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_encode = percent_encode
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _encode_userinfo(userinfo: str) -> str:
|
|
65
|
+
"""A userinfo for composition: the user name escapes its colons, the
|
|
66
|
+
password keeps them (RFC 3986 3.2.1)."""
|
|
67
|
+
user, password = _split_userinfo_text(userinfo)
|
|
68
|
+
text = _encode(user, _SAFE_USER)
|
|
69
|
+
if password is not None:
|
|
70
|
+
text += ":" + _encode(password, _SAFE_USERINFO)
|
|
71
|
+
return text
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _encode_raw_query(query: str) -> str:
|
|
75
|
+
"""Emit a stored query for the wire: it is kept in its received,
|
|
76
|
+
still-percent-encoded form (see `URI.query`), so an existing `%XX`
|
|
77
|
+
escape and every delimiter (`&`, `=`, `+`, `;`) go out unchanged. Only
|
|
78
|
+
characters that cannot appear in a query at all (a space, non-ASCII, a
|
|
79
|
+
`%` that starts no escape) are encoded.
|
|
80
|
+
|
|
81
|
+
Decoding at parse time and re-encoding here with `&`/`=`/`+` as safe
|
|
82
|
+
characters turned `name=a%26b&sig=ab%2Bcd%3D%3D` into
|
|
83
|
+
`name=a&b&sig=ab+cd==` on the wire, corrupting signed URLs."""
|
|
84
|
+
pieces = _PERCENT_ESCAPE.split(query)
|
|
85
|
+
for index in range(0, len(pieces), 2):
|
|
86
|
+
if pieces[index]:
|
|
87
|
+
pieces[index] = _encode(pieces[index], _SAFE_QUERY)
|
|
88
|
+
return "".join(pieces)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _compose_ipv6(address: _ip.IPv6Address) -> str:
|
|
92
|
+
"""A bracketed IPv6 literal; a zone is written after `%25` and
|
|
93
|
+
percent-encoded (RFC 6874)."""
|
|
94
|
+
text, sep, zone = address.compressed.partition("%")
|
|
95
|
+
if sep:
|
|
96
|
+
text = f"{text}%25{_encode(zone, '')}"
|
|
97
|
+
return f"[{text}]"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def compose_host(host: "str | _IPAddress") -> str:
|
|
101
|
+
"""Encode a host for composition: an IPv6 address is bracketed, and so is a
|
|
102
|
+
bare (non-bracketed) string that parses as one, as a manually constructed
|
|
103
|
+
`Source(..., host="::1", ...)` holds it. Deliberately not netimps'
|
|
104
|
+
`join_host`/`FQDN`: those would reject names this composes. An IPv6 zone is
|
|
105
|
+
written as RFC 6874 says."""
|
|
106
|
+
if isinstance(host, _ip.IPv6Address):
|
|
107
|
+
return _compose_ipv6(host)
|
|
108
|
+
if isinstance(host, _ip.IPv4Address):
|
|
109
|
+
return host.compressed
|
|
110
|
+
if host.startswith("[") and host.endswith("]"):
|
|
111
|
+
return _compose_ipv6(_ipv6_from_literal(host[1:-1]))
|
|
112
|
+
try:
|
|
113
|
+
return _compose_ipv6(_ip.IPv6Address(host))
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
if not host.isascii():
|
|
117
|
+
# RFC 3986 3.2.2: a non-ASCII registered name meant for DNS is produced in its
|
|
118
|
+
# IDNA form (HTTP clients IDNA-encode only a non-ASCII host). A name the codec
|
|
119
|
+
# rejects (an empty or over-long label, a surrogate-escaped byte) stays percent-encoded.
|
|
120
|
+
try:
|
|
121
|
+
return _idna_encode(host)
|
|
122
|
+
except UnicodeError:
|
|
123
|
+
pass
|
|
124
|
+
return _encode(host, _SAFE_HOST)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_compose_host = compose_host
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _idna_encode(host: str) -> str:
|
|
131
|
+
"""`host` in IDNA (ASCII) form: IDNA 2008 with UTS #46 mapping through
|
|
132
|
+
the `idna` package when installed (what `requests` itself uses), else
|
|
133
|
+
the stdlib IDNA 2003 codec. Raises UnicodeError for an invalid name."""
|
|
134
|
+
try:
|
|
135
|
+
import idna as _idna
|
|
136
|
+
except ImportError:
|
|
137
|
+
return host.encode("idna").decode("ascii")
|
|
138
|
+
# idna.IDNAError subclasses UnicodeError.
|
|
139
|
+
return _idna.encode(host, uts46=True).decode("ascii")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _port_text(port: "int | str") -> str:
|
|
143
|
+
"""The digits of a port that is 0-65535 (an int, or a `str` of digits);
|
|
144
|
+
anything else is a `ValueError`, so a field that did not come from a
|
|
145
|
+
parse cannot add a path or a second authority to the result."""
|
|
146
|
+
if isinstance(port, str):
|
|
147
|
+
if not port.isascii() or not port.isdigit():
|
|
148
|
+
raise ValueError("invalid port: it must be digits")
|
|
149
|
+
return str(_parse_port(port))
|
|
150
|
+
if isinstance(port, bool) or not isinstance(port, int):
|
|
151
|
+
raise ValueError("invalid port: it must be an integer")
|
|
152
|
+
if not 0 <= port <= 65535:
|
|
153
|
+
raise ValueError("port out of range 0-65535")
|
|
154
|
+
return str(port)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def compose_uri(
|
|
158
|
+
scheme: "str | None",
|
|
159
|
+
userinfo: "str | None",
|
|
160
|
+
host: "str | _IPAddress | None",
|
|
161
|
+
port: "int | str | None",
|
|
162
|
+
path: str,
|
|
163
|
+
query: "str | None",
|
|
164
|
+
fragment: "str | None",
|
|
165
|
+
) -> str:
|
|
166
|
+
"""The URI text of already-decoded components, percent-encoding each as
|
|
167
|
+
its part of RFC 3986 asks. `None` leaves a component out; a path that
|
|
168
|
+
cannot sit beside the authority given (or beside none) is a `ValueError`,
|
|
169
|
+
as is an invalid scheme or port.
|
|
170
|
+
|
|
171
|
+
This is direct string assembly, with no re-validation of every component
|
|
172
|
+
(a scheme regex, authority re-parsing, IP-literal detection), which only
|
|
173
|
+
matters for untrusted input. It replicates only what changes the output
|
|
174
|
+
string (IP-literal bracketing, the "./" escape of a colon in the first
|
|
175
|
+
segment); the tests compare it with `uritools.uricompose()` on generated
|
|
176
|
+
(scheme, userinfo, host, port, path, query, fragment) combinations."""
|
|
177
|
+
parts = []
|
|
178
|
+
if scheme is not None:
|
|
179
|
+
if not _SCHEME_RE.fullmatch(scheme):
|
|
180
|
+
raise ValueError("invalid scheme")
|
|
181
|
+
parts.append(scheme)
|
|
182
|
+
parts.append(":")
|
|
183
|
+
has_authority = userinfo is not None or host is not None or port is not None
|
|
184
|
+
if has_authority:
|
|
185
|
+
parts.append("//")
|
|
186
|
+
if userinfo is not None:
|
|
187
|
+
parts.append(_encode_userinfo(userinfo))
|
|
188
|
+
parts.append("@")
|
|
189
|
+
if host is not None:
|
|
190
|
+
parts.append(_compose_host(host))
|
|
191
|
+
if port is not None:
|
|
192
|
+
parts.append(":")
|
|
193
|
+
parts.append(_port_text(port))
|
|
194
|
+
path_enc = _encode(path, _SAFE_PATH) if path else ""
|
|
195
|
+
if has_authority and path_enc and not path_enc.startswith("/"):
|
|
196
|
+
raise ValueError("Invalid path with authority component")
|
|
197
|
+
if not has_authority and path_enc.startswith("//"):
|
|
198
|
+
raise ValueError("Invalid path without authority component")
|
|
199
|
+
if scheme is None and not has_authority and not path_enc.startswith("/"):
|
|
200
|
+
if ":" in path_enc.partition("/")[0]:
|
|
201
|
+
path_enc = "./" + path_enc
|
|
202
|
+
parts.append(path_enc)
|
|
203
|
+
if query is not None:
|
|
204
|
+
parts.append("?")
|
|
205
|
+
parts.append(_encode_raw_query(query))
|
|
206
|
+
if fragment is not None:
|
|
207
|
+
parts.append("#")
|
|
208
|
+
parts.append(_encode(fragment, _SAFE_FRAGMENT))
|
|
209
|
+
return "".join(parts)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_compose_uri = compose_uri
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading as _threading
|
|
4
|
+
import time as _time
|
|
5
|
+
|
|
6
|
+
from ._parse import _IPAddress
|
|
7
|
+
|
|
8
|
+
#: How long `Source.is_local()` remembers an answer, in seconds, and for how
|
|
9
|
+
#: many hosts.
|
|
10
|
+
_LOCAL_TTL = 60.0
|
|
11
|
+
_LOCAL_CACHE_SIZE = 256
|
|
12
|
+
_local_cache: "dict[object, tuple[float, bool]]" = {}
|
|
13
|
+
_local_lock = _threading.Lock()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _host_is_local(host: "str | _IPAddress") -> bool:
|
|
17
|
+
now = _time.monotonic()
|
|
18
|
+
with _local_lock:
|
|
19
|
+
entry = _local_cache.get(host)
|
|
20
|
+
if entry is not None and entry[0] > now:
|
|
21
|
+
return entry[1]
|
|
22
|
+
answer = _resolve_is_local(host)
|
|
23
|
+
with _local_lock:
|
|
24
|
+
if len(_local_cache) >= _LOCAL_CACHE_SIZE:
|
|
25
|
+
for key in [k for k, (until, _) in _local_cache.items() if until <= now]:
|
|
26
|
+
del _local_cache[key]
|
|
27
|
+
while len(_local_cache) >= _LOCAL_CACHE_SIZE:
|
|
28
|
+
del _local_cache[next(iter(_local_cache))]
|
|
29
|
+
_local_cache[host] = (now + _LOCAL_TTL, answer)
|
|
30
|
+
return answer
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_is_local(host: "str | _IPAddress") -> bool:
|
|
34
|
+
# Imported here, not at module top: only this needs netimps, and every
|
|
35
|
+
# `import uripath` would otherwise pay for its import.
|
|
36
|
+
import netimps as _netimps
|
|
37
|
+
|
|
38
|
+
if not isinstance(host, str):
|
|
39
|
+
return _netimps.is_local_address(host)
|
|
40
|
+
literal = _netimps.try_parse(host)
|
|
41
|
+
if literal is not None:
|
|
42
|
+
return _netimps.is_local_address(literal)
|
|
43
|
+
addresses = _netimps.resolve(host, "a") + _netimps.resolve(host, "aaaa")
|
|
44
|
+
return any(_netimps.is_local_address(address) for address in addresses)
|
uripath/source/_parse.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress as _ip
|
|
4
|
+
import typing as _ty
|
|
5
|
+
|
|
6
|
+
from .. import _rfc3986
|
|
7
|
+
from ..exceptions import URISyntaxError
|
|
8
|
+
|
|
9
|
+
_IPAddress = _ty.Union[_ip.IPv4Address, _ip.IPv6Address]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_DIGITS = "0123456789"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
#: Error handler for percent-escapes. A valid URI may escape bytes that are not
|
|
16
|
+
#: UTF-8 (`caf%E9.html`); `surrogateescape` carries each through the decoded `str`
|
|
17
|
+
#: and back out to the same escape, where `strict` would raise UnicodeDecodeError.
|
|
18
|
+
ERRORS = "surrogateescape"
|
|
19
|
+
_ERRORS = ERRORS
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Userinfo(str):
|
|
23
|
+
"""A decoded userinfo that remembers which colon separated the user name
|
|
24
|
+
from the password. It equals, hashes and prints as the plain
|
|
25
|
+
`user:password` text, so code that treats `Source.userinfo` as a `str`
|
|
26
|
+
is unaffected; only the composer and `Source.parsed_userinfo()` read
|
|
27
|
+
the split. Without it a user name holding an escaped colon (`us%3Aer`)
|
|
28
|
+
could not be told from a password separator once decoded."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("user", "password")
|
|
31
|
+
|
|
32
|
+
def __new__(cls, user: str, password: "str | None" = None):
|
|
33
|
+
obj = str.__new__(cls, user if password is None else f"{user}:{password}")
|
|
34
|
+
obj.user = user
|
|
35
|
+
obj.password = password
|
|
36
|
+
return obj
|
|
37
|
+
|
|
38
|
+
def __reduce__(self):
|
|
39
|
+
return _Userinfo, (self.user, self.password)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _split_userinfo_text(userinfo: str) -> "tuple[str, str | None]":
|
|
43
|
+
"""`(user, password)` of a userinfo: the split `_Userinfo` recorded, else
|
|
44
|
+
at the first colon. `password` is None when there is no colon."""
|
|
45
|
+
if isinstance(userinfo, _Userinfo):
|
|
46
|
+
return userinfo.user, userinfo.password
|
|
47
|
+
user, colon, password = userinfo.partition(":")
|
|
48
|
+
return user, (password if colon else None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _decode_userinfo(raw: str) -> _Userinfo:
|
|
52
|
+
"""Decode a raw userinfo: split at the first literal colon FIRST, then
|
|
53
|
+
decode each half, so an escaped `%3A` stays part of the name."""
|
|
54
|
+
user, colon, password = raw.partition(":")
|
|
55
|
+
return _Userinfo(
|
|
56
|
+
_rfc3986.decode(user, _ERRORS),
|
|
57
|
+
_rfc3986.decode(password, _ERRORS) if colon else None,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse_port(text: str) -> "int | None":
|
|
62
|
+
"""The port a run of ASCII digits names (None for no digits), which must
|
|
63
|
+
be 0-65535 (RFC 3986 3.2.3 leaves it open; TCP and UDP do not). The
|
|
64
|
+
message names no part of the input: it may sit beside a password."""
|
|
65
|
+
if not text:
|
|
66
|
+
return None
|
|
67
|
+
digits = text.lstrip("0") or "0"
|
|
68
|
+
if len(digits) > 5 or int(digits) > 65535:
|
|
69
|
+
raise URISyntaxError("port out of range 0-65535")
|
|
70
|
+
return int(digits)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _split_authority(
|
|
74
|
+
authority: "str | None",
|
|
75
|
+
) -> "tuple[str | None, str | None, int | None]":
|
|
76
|
+
"""One-pass split of a raw URI authority into (userinfo, host, port) --
|
|
77
|
+
RAW/undecoded strings, port as int-or-None. It follows the RFC 3986
|
|
78
|
+
reading of `userinfo@host:port` with one split of the authority; the tests
|
|
79
|
+
compare it with the `uritools` reading and name where it differs:
|
|
80
|
+
|
|
81
|
+
* a host is followed by a port only when a ':' was found, so a host of
|
|
82
|
+
digits alone (`s3://20240101/key`) stays the host, where `uritools`
|
|
83
|
+
reads it as a port and returns an empty host;
|
|
84
|
+
* the port is 0-65535; a ':' followed by anything else that is not a
|
|
85
|
+
bracketed address is an invalid port (`URISyntaxError`), not part of the
|
|
86
|
+
host name."""
|
|
87
|
+
if authority is None:
|
|
88
|
+
return None, None, None
|
|
89
|
+
userinfo, at_sep, hostinfo = authority.rpartition("@")
|
|
90
|
+
if not at_sep:
|
|
91
|
+
userinfo = None
|
|
92
|
+
host, colon, tail = hostinfo.rpartition(":")
|
|
93
|
+
if colon and not tail.lstrip(_DIGITS):
|
|
94
|
+
return userinfo, host, _parse_port(tail)
|
|
95
|
+
if colon and not hostinfo.startswith("["):
|
|
96
|
+
raise URISyntaxError("invalid port: it must be digits")
|
|
97
|
+
return userinfo, hostinfo, None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ipv6_from_literal(literal: str) -> _ip.IPv6Address:
|
|
101
|
+
"""The address a bracketed IPv6 literal names. A zone is introduced by
|
|
102
|
+
`%25` (RFC 6874: the `%` of `address%zone` is escaped inside a URI) and
|
|
103
|
+
is percent-decoded; a bare `%` is read as the delimiter as well, as
|
|
104
|
+
`ipaddress` does."""
|
|
105
|
+
address, sep, zone = literal.partition("%25")
|
|
106
|
+
if sep:
|
|
107
|
+
literal = f"{address}%{_rfc3986.decode(zone, _ERRORS)}"
|
|
108
|
+
try:
|
|
109
|
+
return _ip.IPv6Address(literal)
|
|
110
|
+
except ValueError:
|
|
111
|
+
raise URISyntaxError("invalid IPv6 address in the host") from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _decode_host(host: str) -> "str | _IPAddress":
|
|
115
|
+
"""Decode a raw (still percent-encoded) host string. A bracketed
|
|
116
|
+
literal is an IPv6 address; mismatched brackets and a literal that starts
|
|
117
|
+
with a lower-case `v` (an IPvFuture version flag; RFC 3986 spells it in
|
|
118
|
+
either case, and only the lower-case one is refused) raise
|
|
119
|
+
`URISyntaxError`; a dotted quad is an IPv4 address; anything else is a
|
|
120
|
+
name, percent-decoded. An IPv6 zone is read as RFC 6874 says
|
|
121
|
+
(`_ipv6_from_literal`)."""
|
|
122
|
+
if host.startswith("[") and host.endswith("]"):
|
|
123
|
+
literal = host[1:-1]
|
|
124
|
+
if literal.startswith("v"):
|
|
125
|
+
raise URISyntaxError("address mechanism not supported")
|
|
126
|
+
return _ipv6_from_literal(literal)
|
|
127
|
+
if host.startswith("[") or host.endswith("]"):
|
|
128
|
+
raise URISyntaxError(f"Invalid host {host!r}: mismatched brackets")
|
|
129
|
+
try:
|
|
130
|
+
return _ip.IPv4Address(host)
|
|
131
|
+
except ValueError:
|
|
132
|
+
return _rfc3986.decode(host, _ERRORS)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def fold_host(host: "str | _IPAddress | None") -> "str | _IPAddress | None":
|
|
136
|
+
"""`host` as a comparison or a key reads it: a name in lower case, since
|
|
137
|
+
host names are case-insensitive (RFC 3986 3.2.2), and an address as it is.
|
|
138
|
+
`Source.host` keeps the spelling it was given, so anything that compares
|
|
139
|
+
hosts or uses one as a key folds it at that place."""
|
|
140
|
+
return host.lower() if isinstance(host, str) else host
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def is_drive(segment: str) -> bool:
|
|
144
|
+
"""Whether a path segment is a Windows drive ("C:")."""
|
|
145
|
+
return len(segment) == 2 and segment[1] == ":" and segment[0].isalpha()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_is_drive = is_drive
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _remove_dot_segments(path: str, *, drive: bool = False) -> str:
|
|
152
|
+
"""RFC 3986 5.2.4 dot-segment removal on a path. Apply it to a raw
|
|
153
|
+
(still percent-encoded) path before decoding,
|
|
154
|
+
and again to the decoded path: a `%2e` only becomes a `.` segment
|
|
155
|
+
once decoded. A `..` that would pass the root of an absolute path is
|
|
156
|
+
dropped; a relative path keeps its leading `..`.
|
|
157
|
+
|
|
158
|
+
`drive=True` (a `file:` path on Windows) makes a leading drive segment
|
|
159
|
+
("C:/x", "/C:/x") the anchor, which `..` never climbs above, as for
|
|
160
|
+
`PureWindowsPath`."""
|
|
161
|
+
if drive:
|
|
162
|
+
head = path[1:] if path.startswith("/") else path
|
|
163
|
+
letter, sep, rest = head.partition("/")
|
|
164
|
+
if sep and _is_drive(letter):
|
|
165
|
+
lead = path[: len(path) - len(head)]
|
|
166
|
+
return lead + letter + _remove_dot_segments("/" + rest)
|
|
167
|
+
pseg = []
|
|
168
|
+
for s in path.split("/"):
|
|
169
|
+
if s == ".":
|
|
170
|
+
continue
|
|
171
|
+
elif s != "..":
|
|
172
|
+
pseg.append(s)
|
|
173
|
+
elif len(pseg) == 1 and not pseg[0]:
|
|
174
|
+
continue
|
|
175
|
+
elif pseg and pseg[-1] != "..":
|
|
176
|
+
pseg.pop()
|
|
177
|
+
else:
|
|
178
|
+
pseg.append(s)
|
|
179
|
+
if path.rpartition("/")[2] in (".", ".."):
|
|
180
|
+
pseg.append("")
|
|
181
|
+
if path and len(pseg) == 1 and pseg[0] == "":
|
|
182
|
+
pseg.insert(0, ".")
|
|
183
|
+
return "/".join(pseg)
|