pathapi 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 (104) hide show
  1. pathapi/AGENTS.md +2355 -0
  2. pathapi/README.md +205 -0
  3. pathapi/__init__.py +62 -0
  4. pathapi/_commit.py +116 -0
  5. pathapi/_discard.py +92 -0
  6. pathapi/_errorpolicy.py +106 -0
  7. pathapi/_filestat.py +194 -0
  8. pathapi/_lru.py +138 -0
  9. pathapi/_names.py +70 -0
  10. pathapi/_permissions.py +102 -0
  11. pathapi/_stubs.py +76 -0
  12. pathapi/_tree.py +136 -0
  13. pathapi/archive/__init__.py +44 -0
  14. pathapi/archive/_backend.py +393 -0
  15. pathapi/archive/_names.py +63 -0
  16. pathapi/archive/_pack.py +394 -0
  17. pathapi/archive/_path.py +352 -0
  18. pathapi/archive/_snapshot.py +269 -0
  19. pathapi/archive/_tar.py +126 -0
  20. pathapi/archive/_write.py +359 -0
  21. pathapi/archive/_zip.py +563 -0
  22. pathapi/archive/_zipentries.py +272 -0
  23. pathapi/checksum.py +61 -0
  24. pathapi/exceptions.py +22 -0
  25. pathapi/fspath.py +513 -0
  26. pathapi/ftp/__init__.py +27 -0
  27. pathapi/ftp/_backend.py +418 -0
  28. pathapi/ftp/_facts.py +117 -0
  29. pathapi/ftp/_path.py +582 -0
  30. pathapi/glob/__init__.py +27 -0
  31. pathapi/glob/_entries.py +103 -0
  32. pathapi/glob/_patterns.py +164 -0
  33. pathapi/glob/_recursion.py +180 -0
  34. pathapi/glob/_selection.py +303 -0
  35. pathapi/http/__init__.py +84 -0
  36. pathapi/http/_body.py +156 -0
  37. pathapi/http/_builtin.py +553 -0
  38. pathapi/http/_client.py +211 -0
  39. pathapi/http/_errors.py +79 -0
  40. pathapi/http/_follow.py +230 -0
  41. pathapi/http/_headers.py +233 -0
  42. pathapi/http/_pool.py +169 -0
  43. pathapi/http/_proxies.py +142 -0
  44. pathapi/http/_response.py +255 -0
  45. pathapi/http/_settings.py +12 -0
  46. pathapi/http/_status.py +68 -0
  47. pathapi/http/_transports.py +173 -0
  48. pathapi/http/testing/__init__.py +80 -0
  49. pathapi/http/testing/__main__.py +10 -0
  50. pathapi/http/testing/suite/conftest.py +98 -0
  51. pathapi/http/testing/suite/test_client_default.py +285 -0
  52. pathapi/http/testing/suite/test_client_errors.py +181 -0
  53. pathapi/http/testing/suite/test_client_peer_text.py +86 -0
  54. pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
  55. pathapi/http/testing/suite/test_client_redirects.py +231 -0
  56. pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
  57. pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
  58. pathapi/http/testing/suite/test_transport_body.py +302 -0
  59. pathapi/http/testing/suite/test_transport_send.py +662 -0
  60. pathapi/http/testing/suite/transport_fakes.py +133 -0
  61. pathapi/http/testing/suite/transport_guard.py +174 -0
  62. pathapi/http/testing/suite/transport_wire.py +511 -0
  63. pathapi/mempath.py +438 -0
  64. pathapi/path/__init__.py +19 -0
  65. pathapi/path/_info.py +10 -0
  66. pathapi/path/_path.py +198 -0
  67. pathapi/path/_pure.py +522 -0
  68. pathapi/path/_relation.py +10 -0
  69. pathapi/path/_transfer.py +10 -0
  70. pathapi/protocols/__init__.py +35 -0
  71. pathapi/protocols/_bundles.py +22 -0
  72. pathapi/protocols/_errors.py +12 -0
  73. pathapi/protocols/_info.py +65 -0
  74. pathapi/protocols/_relation.py +191 -0
  75. pathapi/protocols/_removal.py +69 -0
  76. pathapi/protocols/_support.py +122 -0
  77. pathapi/protocols/_transfer.py +100 -0
  78. pathapi/protocols/checksum.py +80 -0
  79. pathapi/protocols/fs.py +429 -0
  80. pathapi/protocols/io.py +224 -0
  81. pathapi/protocols/links.py +135 -0
  82. pathapi/protocols/pure.py +31 -0
  83. pathapi/protocols/resolve.py +97 -0
  84. pathapi/protocols/transfer.py +656 -0
  85. pathapi/protocols/tree.py +492 -0
  86. pathapi/providers.py +48 -0
  87. pathapi/py.typed +1 -0
  88. pathapi/sync/__init__.py +8 -0
  89. pathapi/sync/_compare.py +157 -0
  90. pathapi/sync/_events.py +97 -0
  91. pathapi/sync/_links.py +93 -0
  92. pathapi/sync/_offer.py +33 -0
  93. pathapi/sync/_paths.py +50 -0
  94. pathapi/sync/_replace.py +98 -0
  95. pathapi/sync/_syncer.py +989 -0
  96. pathapi/testing/__init__.py +96 -0
  97. pathapi/testing/_contract.py +522 -0
  98. pathapi/testing/_fixtures.py +83 -0
  99. pathapi/testing/_read.py +279 -0
  100. pathapi-0.1.0.dist-info/METADATA +242 -0
  101. pathapi-0.1.0.dist-info/RECORD +104 -0
  102. pathapi-0.1.0.dist-info/WHEEL +4 -0
  103. pathapi-0.1.0.dist-info/entry_points.txt +2 -0
  104. pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,233 @@
1
+ """Headers and the checks a request passes before anything is sent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections.abc as _abc
6
+ import re as _re
7
+ import typing as _ty
8
+ import urllib.parse as _urlparse
9
+
10
+ _TOKEN = _re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
11
+ _CONTROL = _re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
12
+ _URL_CONTROL = _re.compile(r"[\x00-\x1f\x7f]")
13
+ _NOT_PRINTABLE = _re.compile(r"[^\x21-\x7e]")
14
+
15
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
16
+
17
+
18
+ class Headers(_abc.Mapping):
19
+ """The headers of a response: a read-only mapping whose names compare
20
+ without regard to case. A name sent more than once reads as its values
21
+ joined with `", "` (RFC 9110 5.3); `get_all()` returns them separately."""
22
+
23
+ __slots__ = ("_pairs", "_joined", "_spelling")
24
+
25
+ def __init__(self, pairs: "_ty.Iterable[tuple[str, str]] | _abc.Mapping" = ()):
26
+ if isinstance(pairs, _abc.Mapping):
27
+ pairs = pairs.items()
28
+ self._pairs = tuple((str(name), str(value)) for name, value in pairs)
29
+ grouped: "dict[str, list[str]]" = {}
30
+ self._spelling: "dict[str, str]" = {}
31
+ for name, value in self._pairs:
32
+ key = name.lower()
33
+ grouped.setdefault(key, []).append(value)
34
+ self._spelling.setdefault(key, name)
35
+ self._joined = {key: ", ".join(values) for key, values in grouped.items()}
36
+
37
+ def __getitem__(self, name: str) -> str:
38
+ return self._joined[name.lower()]
39
+
40
+ def __iter__(self) -> "_ty.Iterator[str]":
41
+ return iter(self._spelling.values())
42
+
43
+ def __len__(self) -> int:
44
+ return len(self._joined)
45
+
46
+ def __contains__(self, name: object) -> bool:
47
+ return isinstance(name, str) and name.lower() in self._joined
48
+
49
+ def get_all(self, name: str) -> "list[str]":
50
+ """Every value sent for `name`, in order."""
51
+ key = name.lower()
52
+ return [value for field, value in self._pairs if field.lower() == key]
53
+
54
+ def __repr__(self) -> str:
55
+ # Values are left out: a reply can carry cookies and tokens.
56
+ return f"Headers({list(self._spelling.values())!r})"
57
+
58
+
59
+ def peer_text(text: object, limit: int = 120) -> str:
60
+ """`text` that came from the other end (a reason phrase, a `Location`, a
61
+ header value) as an error message may show it: characters a terminal or a
62
+ log would act on (controls, line and bidirectional separators) escaped, and
63
+ cut to `limit` characters."""
64
+ text = str(text)
65
+ cut = len(text) > limit
66
+ shown = "".join(
67
+ char if char.isprintable() else char.encode("unicode_escape").decode("ascii")
68
+ for char in text[:limit]
69
+ )
70
+ return shown + "..." if cut else shown
71
+
72
+
73
+ def has_header(headers: "_abc.Mapping[str, str]", name: str) -> bool:
74
+ key = name.lower()
75
+ return any(field.lower() == key for field in headers)
76
+
77
+
78
+ def wants_decoding(pairs: "_ty.Iterable[tuple[str, str]]") -> bool:
79
+ """Whether a response is decoded for a request with these headers: unless
80
+ the request names a content coding of its own in `Accept-Encoding`
81
+ (`identity` is not one, so a server that encodes anyway is still decoded)."""
82
+ for name, value in pairs:
83
+ if name.lower() == "accept-encoding":
84
+ tokens = {part.split(";")[0].strip().lower() for part in value.split(",")}
85
+ return tokens <= {"", "identity"}
86
+ return True
87
+
88
+
89
+ def drop_header(headers: "_abc.Mapping[str, str]", name: str) -> "dict[str, str]":
90
+ """`headers` as a new dict without `name`, whatever its case."""
91
+ key = name.lower()
92
+ return {field: value for field, value in headers.items() if field.lower() != key}
93
+
94
+
95
+ def merge_headers(*layers: "_abc.Mapping[str, str] | None") -> "dict[str, str]":
96
+ """The layers laid over one another, a later one winning on a name whatever
97
+ its case; a value of `None` removes the name."""
98
+ merged: "dict[str, tuple[str, str]]" = {}
99
+ for layer in layers:
100
+ for name, value in (layer or {}).items():
101
+ if value is None:
102
+ merged.pop(name.lower(), None)
103
+ else:
104
+ merged[name.lower()] = (name, value)
105
+ return dict(merged.values())
106
+
107
+
108
+ def check_timeout(timeout) -> "tuple[float | None, float | None]":
109
+ """`timeout` (a number, `None`, or a `(connect, read)` pair) as that pair;
110
+ `ValueError` for anything else."""
111
+ if timeout is None:
112
+ return None, None
113
+ if isinstance(timeout, tuple):
114
+ if len(timeout) != 2:
115
+ raise ValueError("a timeout is a number, None or a (connect, read) pair")
116
+ connect, read = timeout
117
+ else:
118
+ connect = read = timeout
119
+ for value in (connect, read):
120
+ if value is not None and (
121
+ isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0
122
+ ):
123
+ raise ValueError("a timeout is a positive number of seconds, or None")
124
+ return connect, read
125
+
126
+
127
+ def check_method(method: str) -> str:
128
+ if not isinstance(method, str) or not _TOKEN.fullmatch(method):
129
+ raise ValueError("the request method is not an HTTP token")
130
+ return method.upper()
131
+
132
+
133
+ def check_body(body) -> bool:
134
+ """Whether `body` (`None`, bytes, a binary file or an iterable of bytes)
135
+ can be sent twice; `TypeError` for text or anything else."""
136
+ if body is None or isinstance(body, (bytes, bytearray, memoryview)):
137
+ return True
138
+ if isinstance(body, str):
139
+ raise TypeError("the request body is bytes, not text")
140
+ if hasattr(body, "read"):
141
+ return False
142
+ if isinstance(body, (list, tuple)):
143
+ return all(isinstance(piece, (bytes, bytearray)) for piece in body)
144
+ if hasattr(body, "__iter__"):
145
+ return False
146
+ raise TypeError("the request body is bytes, a file or an iterable of bytes")
147
+
148
+
149
+ def check_headers(headers: "_abc.Mapping[str, str] | None") -> "list[tuple[str, str]]":
150
+ """`headers` as `(name, value)` text pairs, or `ValueError` for a name that
151
+ is not an HTTP token or a value with a control character, a line break
152
+ included. The refused text is not quoted: it may be a credential."""
153
+ pairs = []
154
+ for name, value in (headers or {}).items():
155
+ if not isinstance(name, str) or not _TOKEN.fullmatch(name):
156
+ raise ValueError("a request header name is not an HTTP token")
157
+ if isinstance(value, bytes):
158
+ value = value.decode("latin-1")
159
+ elif isinstance(value, int) and not isinstance(value, bool):
160
+ value = str(value)
161
+ if not isinstance(value, str):
162
+ raise ValueError(f"the value of the {name} header is not text")
163
+ if _CONTROL.search(value):
164
+ raise ValueError(f"the value of the {name} header has a control character")
165
+ try:
166
+ value.encode("latin-1")
167
+ except UnicodeEncodeError:
168
+ raise ValueError(f"the value of the {name} header is not Latin-1") from None
169
+ pairs.append((name, value))
170
+ return pairs
171
+
172
+
173
+ class Target(_ty.NamedTuple):
174
+ """Where a request goes and how it names itself on the wire."""
175
+
176
+ scheme: str
177
+ host: str # IDNA, without brackets
178
+ port: int
179
+ path: str # the origin-form request target, percent-encoded
180
+ authority: str # the Host header value
181
+
182
+
183
+ def _percent(match: "_re.Match") -> str:
184
+ return "".join(
185
+ f"%{byte:02X}" for byte in match.group().encode("utf-8", "surrogateescape")
186
+ )
187
+
188
+
189
+ def bracket(host: str) -> str:
190
+ return f"[{host}]" if ":" in host else host
191
+
192
+
193
+ def check_url(url: str) -> Target:
194
+ """The parts of an `http:`/`https:` URL a request is made of. A control
195
+ character (CR and LF included), credentials in the URL, another scheme or
196
+ a missing host are `ValueError`; the URL is not quoted in the message."""
197
+ if not isinstance(url, str):
198
+ raise ValueError("the request URL is not text")
199
+ if _URL_CONTROL.search(url):
200
+ raise ValueError("the request URL has a control character")
201
+ try:
202
+ parts = _urlparse.urlsplit(url)
203
+ port = parts.port
204
+ except ValueError:
205
+ raise ValueError("the request URL cannot be parsed") from None
206
+ scheme = parts.scheme.lower()
207
+ if scheme not in _DEFAULT_PORTS:
208
+ raise ValueError(f"the request URL has scheme {scheme!r}, not http or https")
209
+ if "@" in parts.netloc:
210
+ raise ValueError(
211
+ "the request URL carries credentials; send them in an Authorization header"
212
+ )
213
+ # The connection pool, the Host header, SNI and the proxy rules key on this
214
+ # name, which is case-insensitive: `hostname` is lower case already.
215
+ host = (parts.hostname or "").lower()
216
+ if not host:
217
+ raise ValueError("the request URL has no host")
218
+ if not host.isascii():
219
+ try:
220
+ host = host.encode("idna").decode("ascii")
221
+ except UnicodeError:
222
+ raise ValueError(
223
+ "the host of the request URL is not a valid name"
224
+ ) from None
225
+ port = port or _DEFAULT_PORTS[scheme]
226
+ path = parts.path or "/"
227
+ if parts.query:
228
+ path += "?" + parts.query
229
+ path = _NOT_PRINTABLE.sub(_percent, path)
230
+ authority = bracket(host)
231
+ if port != _DEFAULT_PORTS[scheme]:
232
+ authority += f":{port}"
233
+ return Target(scheme, host, port, path, authority)
pathapi/http/_pool.py ADDED
@@ -0,0 +1,169 @@
1
+ """The idle connections of a transport: one per thread and server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections as _collections
6
+ import itertools as _itertools
7
+ import socket as _socket
8
+ import threading as _threading
9
+ import typing as _ty
10
+ import weakref as _weakref
11
+
12
+ MAX_IDLE_PER_THREAD = 8
13
+ """Most idle connections one thread keeps open; opening another closes the one
14
+ used longest ago."""
15
+
16
+
17
+ class Connection:
18
+ """An open `http.client.HTTPConnection` and what the pool knows of it."""
19
+
20
+ __slots__ = ("http", "key", "scope", "released_at", "generation", "__weakref__")
21
+
22
+ def __init__(self, http, key: tuple, scope: int, generation: int):
23
+ self.http = http
24
+ self.key = key
25
+ self.scope = scope
26
+ self.generation = generation
27
+ self.released_at = 0.0
28
+
29
+ def close(self, interrupt: bool = False) -> None:
30
+ """Closes the socket. `interrupt` also shuts it down first, which wakes
31
+ a thread blocked on it."""
32
+ http = self.http
33
+ sock = getattr(http, "sock", None)
34
+ try:
35
+ if interrupt and sock is not None:
36
+ try:
37
+ sock.shutdown(_socket.SHUT_RDWR)
38
+ except OSError:
39
+ pass
40
+ finally:
41
+ try:
42
+ http.close()
43
+ except Exception:
44
+ pass
45
+
46
+
47
+ _SCOPE_IDS = _itertools.count(1)
48
+ _THREAD = _threading.local()
49
+ _LIVE_SCOPES: "set[int]" = set()
50
+ _POOLS: "_weakref.WeakSet[Pool]" = _weakref.WeakSet()
51
+
52
+
53
+ def _drop_scope(scope: int) -> None:
54
+ _LIVE_SCOPES.discard(scope)
55
+ for pool in list(_POOLS):
56
+ pool.drop_scope(scope)
57
+
58
+
59
+ class _ThreadScope:
60
+ """Held in the thread's own storage, so it is released when the thread ends
61
+ and takes the thread's idle connections with it."""
62
+
63
+ def __init__(self):
64
+ self.id = next(_SCOPE_IDS)
65
+ _LIVE_SCOPES.add(self.id)
66
+ _weakref.finalize(self, _drop_scope, self.id).atexit = False
67
+
68
+
69
+ def scope_id() -> int:
70
+ """The calling thread's scope. Not the thread identifier, which the
71
+ operating system reuses for a later thread."""
72
+ scope = getattr(_THREAD, "scope", None)
73
+ if scope is None:
74
+ scope = _THREAD.scope = _ThreadScope()
75
+ return scope.id
76
+
77
+
78
+ class Pool:
79
+ """Idle connections by (thread scope, server), and every connection a
80
+ transport has open, so `close()` reaches the ones in use too. A connection
81
+ is taken out of the pool for the length of a response and given back once
82
+ the body has been read."""
83
+
84
+ def __init__(self):
85
+ self._lock = _threading.Lock()
86
+ self._idle: "dict[int, _collections.OrderedDict[tuple, Connection]]" = {}
87
+ self._open: "set[Connection]" = set()
88
+ self._generation = 0
89
+ _POOLS.add(self)
90
+
91
+ @property
92
+ def generation(self) -> int:
93
+ return self._generation
94
+
95
+ def take(self, key: tuple, scope: int) -> "Connection | None":
96
+ """The idle connection of the calling thread to `key`, removed from the
97
+ pool, or `None`."""
98
+ with self._lock:
99
+ return self._idle.get(scope, {}).pop(key, None)
100
+
101
+ def register(self, connection: Connection) -> None:
102
+ with self._lock:
103
+ self._open.add(connection)
104
+
105
+ def give_back(self, connection: Connection, now: float) -> None:
106
+ """Keeps `connection` idle for the next request of its thread, or
107
+ closes it when its thread has ended, the transport was closed since it
108
+ was opened, or the thread already holds one to the same server."""
109
+ connection.released_at = now
110
+ evicted = []
111
+ with self._lock:
112
+ keep = (
113
+ connection.scope in _LIVE_SCOPES
114
+ and connection.generation == self._generation
115
+ )
116
+ if keep:
117
+ idle = self._idle.setdefault(
118
+ connection.scope, _collections.OrderedDict()
119
+ )
120
+ if connection.key in idle:
121
+ keep = False
122
+ else:
123
+ idle[connection.key] = connection
124
+ while len(idle) > MAX_IDLE_PER_THREAD:
125
+ evicted.append(idle.popitem(last=False)[1])
126
+ if not keep:
127
+ self._open.discard(connection)
128
+ evicted.append(connection)
129
+ for old in evicted:
130
+ self._open.discard(old)
131
+ for old in evicted:
132
+ old.close()
133
+
134
+ def discard(self, connection: Connection) -> None:
135
+ """Closes a connection that is not to be used again."""
136
+ with self._lock:
137
+ self._open.discard(connection)
138
+ connection.close()
139
+
140
+ def drop_scope(self, scope: int) -> None:
141
+ with self._lock:
142
+ idle = self._idle.pop(scope, {})
143
+ for connection in idle.values():
144
+ self._open.discard(connection)
145
+ for connection in list(idle.values()):
146
+ connection.close()
147
+
148
+ def close(self) -> None:
149
+ """Closes every connection, idle or in use. Harmless when called again;
150
+ a later request connects afresh."""
151
+ with self._lock:
152
+ self._generation += 1
153
+ doomed = list(self._open)
154
+ self._open.clear()
155
+ self._idle.clear()
156
+ for connection in doomed:
157
+ connection.close(interrupt=True)
158
+
159
+
160
+ def close_pool(pool: Pool) -> None:
161
+ pool.close()
162
+
163
+
164
+ def finalize_with(owner: object, pool: Pool) -> "_ty.Any":
165
+ """Closes `pool` when `owner` is collected, so a transport that was never
166
+ closed does not leave sockets for the interpreter to report."""
167
+ handle = _weakref.finalize(owner, close_pool, pool)
168
+ handle.atexit = False
169
+ return handle
@@ -0,0 +1,142 @@
1
+ """Which proxies a request goes through, from the environment or a provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64 as _base64
6
+ import errno as _errno
7
+ import ipaddress as _ipaddress
8
+ import typing as _ty
9
+ import urllib.parse as _urlparse
10
+ import urllib.request as _urlrequest
11
+
12
+ REQUESTS_HINT = "use the requests transport of pathapi-requests for it"
13
+
14
+
15
+ class ProxyAddress:
16
+ """An `http://` proxy: where to connect, and the `Proxy-Authorization`
17
+ value built from the credentials in its URL. Neither the credentials nor
18
+ the URL are in `repr()` or in any message."""
19
+
20
+ __slots__ = ("host", "port", "authorization")
21
+
22
+ def __init__(self, host: str, port: int, authorization: "str | None"):
23
+ self.host = host
24
+ self.port = port
25
+ self.authorization = authorization
26
+
27
+ @property
28
+ def key(self) -> "tuple[str, int]":
29
+ return (self.host, self.port)
30
+
31
+ def __repr__(self) -> str:
32
+ return f"ProxyAddress({self.host!r}, {self.port})"
33
+
34
+
35
+ def _address(proxy: object) -> ProxyAddress:
36
+ """The address a proxy names: a URL, or any object with a `url`."""
37
+ url = proxy if isinstance(proxy, str) else getattr(proxy, "url", None)
38
+ if not isinstance(url, str) or not url.strip():
39
+ raise ValueError("a proxy is a URL or an object with a url")
40
+ url = url.strip()
41
+ parts = _urlparse.urlsplit(url if "://" in url else "http://" + url)
42
+ scheme = parts.scheme.lower()
43
+ if scheme != "http":
44
+ raise OSError(
45
+ _errno.EPROTONOSUPPORT,
46
+ f"the built-in HTTP transport has no {scheme!r} proxy support; "
47
+ + REQUESTS_HINT,
48
+ )
49
+ try:
50
+ host, port = parts.hostname, parts.port
51
+ except ValueError:
52
+ raise ValueError("a proxy URL cannot be parsed") from None
53
+ if not host:
54
+ raise ValueError("a proxy URL has no host")
55
+ user, password = parts.username, parts.password
56
+ if user is None and not isinstance(proxy, str):
57
+ user = getattr(proxy, "username", None) or None
58
+ password = getattr(proxy, "password", None) or None
59
+ authorization = None
60
+ if user is not None:
61
+ raw = _urlparse.unquote(user) + ":" + _urlparse.unquote(password or "")
62
+ authorization = "Basic " + _base64.b64encode(raw.encode("utf-8")).decode(
63
+ "ascii"
64
+ )
65
+ return ProxyAddress(host, port or 80, authorization)
66
+
67
+
68
+ def _from_provider(found: object) -> "list[ProxyAddress | None]":
69
+ if found is None:
70
+ return [None]
71
+ if isinstance(found, str) or hasattr(found, "url"):
72
+ return [_address(found)]
73
+ try:
74
+ items = list(found)
75
+ except TypeError:
76
+ raise ValueError("a proxy is a URL or an object with a url") from None
77
+ chain = [None if item is None else _address(item) for item in items]
78
+ return chain or [None]
79
+
80
+
81
+ def _in_no_proxy_network(host: str, no_proxy: str) -> bool:
82
+ """Whether `host`, an IP literal, is inside a CIDR block (`10.0.0.0/8`,
83
+ `fd00::/8`) listed in `no_proxy`: the form `requests` honours and
84
+ `urllib.request.proxy_bypass()` does not."""
85
+ try:
86
+ address = _ipaddress.ip_address(host.strip("[]").split("%", 1)[0])
87
+ except ValueError:
88
+ return False
89
+ for entry in no_proxy.split(","):
90
+ if "/" not in entry:
91
+ continue
92
+ try:
93
+ network = _ipaddress.ip_network(entry.strip(), strict=False)
94
+ except ValueError:
95
+ continue
96
+ if address.version == network.version and address in network:
97
+ return True
98
+ return False
99
+
100
+
101
+ def _lookup(provider: _ty.Any, url: str, scheme: str, host: str) -> object:
102
+ """`provider[url]`; for a plain `dict`, the keys `requests` uses for its
103
+ `proxies` too (`scheme://host`, `scheme`, `all://host`, `all`), so a
104
+ mapping written for it is not silently ignored."""
105
+ try:
106
+ return provider[url]
107
+ except KeyError:
108
+ if not isinstance(provider, dict):
109
+ raise
110
+ for key in (f"{scheme}://{host}", scheme, f"all://{host}", "all"):
111
+ if key in provider:
112
+ return provider[key]
113
+ raise KeyError(url)
114
+
115
+
116
+ def proxies_for(
117
+ provider: _ty.Any, url: str, scheme: str, host: str
118
+ ) -> "list[ProxyAddress | None]":
119
+ """The proxies to try for `url`, in order; `None` stands for no proxy.
120
+
121
+ Without a `provider` the environment decides (`urllib.request.getproxies()`
122
+ and `proxy_bypass()`, and the CIDR blocks of `NO_PROXY` for an IP-literal
123
+ host). A provider is indexed with the URL: `KeyError` means no proxy, and
124
+ the environment is not asked; an empty answer is no proxy too."""
125
+ if provider is None:
126
+ chosen = _urlrequest.getproxies()
127
+ value = chosen.get(scheme) or chosen.get("all")
128
+ if not value:
129
+ return [None]
130
+ try:
131
+ if _urlrequest.proxy_bypass(host):
132
+ return [None]
133
+ except Exception:
134
+ pass # a bypass rule that cannot be read does not send it direct
135
+ if _in_no_proxy_network(host, chosen.get("no", "")):
136
+ return [None]
137
+ return [_address(value)]
138
+ try:
139
+ found = _lookup(provider, url, scheme, host)
140
+ except KeyError:
141
+ return [None]
142
+ return _from_provider(found)