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,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)
@@ -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)