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.
- pathapi/AGENTS.md +2355 -0
- pathapi/README.md +205 -0
- pathapi/__init__.py +62 -0
- pathapi/_commit.py +116 -0
- pathapi/_discard.py +92 -0
- pathapi/_errorpolicy.py +106 -0
- pathapi/_filestat.py +194 -0
- pathapi/_lru.py +138 -0
- pathapi/_names.py +70 -0
- pathapi/_permissions.py +102 -0
- pathapi/_stubs.py +76 -0
- pathapi/_tree.py +136 -0
- pathapi/archive/__init__.py +44 -0
- pathapi/archive/_backend.py +393 -0
- pathapi/archive/_names.py +63 -0
- pathapi/archive/_pack.py +394 -0
- pathapi/archive/_path.py +352 -0
- pathapi/archive/_snapshot.py +269 -0
- pathapi/archive/_tar.py +126 -0
- pathapi/archive/_write.py +359 -0
- pathapi/archive/_zip.py +563 -0
- pathapi/archive/_zipentries.py +272 -0
- pathapi/checksum.py +61 -0
- pathapi/exceptions.py +22 -0
- pathapi/fspath.py +513 -0
- pathapi/ftp/__init__.py +27 -0
- pathapi/ftp/_backend.py +418 -0
- pathapi/ftp/_facts.py +117 -0
- pathapi/ftp/_path.py +582 -0
- pathapi/glob/__init__.py +27 -0
- pathapi/glob/_entries.py +103 -0
- pathapi/glob/_patterns.py +164 -0
- pathapi/glob/_recursion.py +180 -0
- pathapi/glob/_selection.py +303 -0
- pathapi/http/__init__.py +84 -0
- pathapi/http/_body.py +156 -0
- pathapi/http/_builtin.py +553 -0
- pathapi/http/_client.py +211 -0
- pathapi/http/_errors.py +79 -0
- pathapi/http/_follow.py +230 -0
- pathapi/http/_headers.py +233 -0
- pathapi/http/_pool.py +169 -0
- pathapi/http/_proxies.py +142 -0
- pathapi/http/_response.py +255 -0
- pathapi/http/_settings.py +12 -0
- pathapi/http/_status.py +68 -0
- pathapi/http/_transports.py +173 -0
- pathapi/http/testing/__init__.py +80 -0
- pathapi/http/testing/__main__.py +10 -0
- pathapi/http/testing/suite/conftest.py +98 -0
- pathapi/http/testing/suite/test_client_default.py +285 -0
- pathapi/http/testing/suite/test_client_errors.py +181 -0
- pathapi/http/testing/suite/test_client_peer_text.py +86 -0
- pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
- pathapi/http/testing/suite/test_client_redirects.py +231 -0
- pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
- pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
- pathapi/http/testing/suite/test_transport_body.py +302 -0
- pathapi/http/testing/suite/test_transport_send.py +662 -0
- pathapi/http/testing/suite/transport_fakes.py +133 -0
- pathapi/http/testing/suite/transport_guard.py +174 -0
- pathapi/http/testing/suite/transport_wire.py +511 -0
- pathapi/mempath.py +438 -0
- pathapi/path/__init__.py +19 -0
- pathapi/path/_info.py +10 -0
- pathapi/path/_path.py +198 -0
- pathapi/path/_pure.py +522 -0
- pathapi/path/_relation.py +10 -0
- pathapi/path/_transfer.py +10 -0
- pathapi/protocols/__init__.py +35 -0
- pathapi/protocols/_bundles.py +22 -0
- pathapi/protocols/_errors.py +12 -0
- pathapi/protocols/_info.py +65 -0
- pathapi/protocols/_relation.py +191 -0
- pathapi/protocols/_removal.py +69 -0
- pathapi/protocols/_support.py +122 -0
- pathapi/protocols/_transfer.py +100 -0
- pathapi/protocols/checksum.py +80 -0
- pathapi/protocols/fs.py +429 -0
- pathapi/protocols/io.py +224 -0
- pathapi/protocols/links.py +135 -0
- pathapi/protocols/pure.py +31 -0
- pathapi/protocols/resolve.py +97 -0
- pathapi/protocols/transfer.py +656 -0
- pathapi/protocols/tree.py +492 -0
- pathapi/providers.py +48 -0
- pathapi/py.typed +1 -0
- pathapi/sync/__init__.py +8 -0
- pathapi/sync/_compare.py +157 -0
- pathapi/sync/_events.py +97 -0
- pathapi/sync/_links.py +93 -0
- pathapi/sync/_offer.py +33 -0
- pathapi/sync/_paths.py +50 -0
- pathapi/sync/_replace.py +98 -0
- pathapi/sync/_syncer.py +989 -0
- pathapi/testing/__init__.py +96 -0
- pathapi/testing/_contract.py +522 -0
- pathapi/testing/_fixtures.py +83 -0
- pathapi/testing/_read.py +279 -0
- pathapi-0.1.0.dist-info/METADATA +242 -0
- pathapi-0.1.0.dist-info/RECORD +104 -0
- pathapi-0.1.0.dist-info/WHEEL +4 -0
- pathapi-0.1.0.dist-info/entry_points.txt +2 -0
- pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
pathapi/http/_headers.py
ADDED
|
@@ -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
|
pathapi/http/_proxies.py
ADDED
|
@@ -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)
|