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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""The response every transport returns, with the decoding they share."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno as _errno
|
|
6
|
+
import typing as _ty
|
|
7
|
+
import zlib as _zlib
|
|
8
|
+
|
|
9
|
+
from ._headers import Headers
|
|
10
|
+
|
|
11
|
+
CHUNK = 64 * 1024
|
|
12
|
+
"""Most bytes read from a transport's body in one call."""
|
|
13
|
+
|
|
14
|
+
_STEP = 256 * 1024
|
|
15
|
+
"""Most decoded bytes one decompression step produces. The compressed bytes
|
|
16
|
+
that are left wait for the next step, so a body that expands a thousandfold
|
|
17
|
+
is never held whole because its header said it was small."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Memory:
|
|
21
|
+
"""A body that is already in memory."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, data):
|
|
24
|
+
self._data = memoryview(bytes(data))
|
|
25
|
+
self._at = 0
|
|
26
|
+
|
|
27
|
+
def read(self, amount: int) -> bytes:
|
|
28
|
+
chunk = bytes(self._data[self._at : self._at + amount])
|
|
29
|
+
self._at += len(chunk)
|
|
30
|
+
return chunk
|
|
31
|
+
|
|
32
|
+
def close(self) -> None:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Inflater:
|
|
37
|
+
"""Decompresses one `gzip` or `deflate` body in bounded steps. `deflate` is
|
|
38
|
+
read as zlib-wrapped data, or as raw data when the zlib header is absent,
|
|
39
|
+
which is what servers send under that name; concatenated gzip members are
|
|
40
|
+
one body."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, gzip: bool):
|
|
43
|
+
self._gzip = gzip
|
|
44
|
+
self._object = None
|
|
45
|
+
self._input = b""
|
|
46
|
+
self._members = 0
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def has_input(self) -> bool:
|
|
50
|
+
return bool(self._input)
|
|
51
|
+
|
|
52
|
+
def feed(self, data: bytes) -> None:
|
|
53
|
+
self._input += data
|
|
54
|
+
|
|
55
|
+
def _new(self, data: bytes):
|
|
56
|
+
if self._gzip:
|
|
57
|
+
return _zlib.decompressobj(16 + _zlib.MAX_WBITS)
|
|
58
|
+
wrapped = len(data) < 2 or (
|
|
59
|
+
data[0] & 0x0F == 8 and ((data[0] << 8) | data[1]) % 31 == 0
|
|
60
|
+
)
|
|
61
|
+
return _zlib.decompressobj(_zlib.MAX_WBITS if wrapped else -_zlib.MAX_WBITS)
|
|
62
|
+
|
|
63
|
+
def step(self) -> bytes:
|
|
64
|
+
data = self._input
|
|
65
|
+
if self._object is None:
|
|
66
|
+
self._object = self._new(data)
|
|
67
|
+
try:
|
|
68
|
+
out = self._object.decompress(data, _STEP)
|
|
69
|
+
except _zlib.error:
|
|
70
|
+
if self._members:
|
|
71
|
+
# Bytes after the last complete member are not a body.
|
|
72
|
+
self._input, self._object = b"", None
|
|
73
|
+
return b""
|
|
74
|
+
kind = "gzip" if self._gzip else "deflate"
|
|
75
|
+
raise OSError(
|
|
76
|
+
_errno.EIO, f"the response body is not valid {kind} data"
|
|
77
|
+
) from None
|
|
78
|
+
if self._object.eof:
|
|
79
|
+
self._members += 1
|
|
80
|
+
self._input = self._object.unused_data
|
|
81
|
+
self._object = None
|
|
82
|
+
else:
|
|
83
|
+
self._input = self._object.unconsumed_tail
|
|
84
|
+
if not out and len(self._input) >= len(data):
|
|
85
|
+
self._input = b""
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
def finish(self) -> None:
|
|
89
|
+
if self._object is not None:
|
|
90
|
+
raise OSError(_errno.EIO, "the compressed response body ended early")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _inflater_for(headers: Headers) -> "_Inflater | None":
|
|
94
|
+
names = [
|
|
95
|
+
name.strip().lower()
|
|
96
|
+
for name in headers.get("Content-Encoding", "").split(",")
|
|
97
|
+
if name.strip().lower() not in ("", "identity")
|
|
98
|
+
]
|
|
99
|
+
if len(names) != 1:
|
|
100
|
+
return None
|
|
101
|
+
if names[0] in ("gzip", "x-gzip"):
|
|
102
|
+
return _Inflater(gzip=True)
|
|
103
|
+
if names[0] == "deflate":
|
|
104
|
+
return _Inflater(gzip=False)
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class HTTPResponse:
|
|
109
|
+
"""One response: `status`, `reason`, `headers` (a read-only mapping that
|
|
110
|
+
ignores the case of a name), `url` (the URL the request went to) and the
|
|
111
|
+
body, read with `read()` or `iter_bytes()`. A context manager that closes.
|
|
112
|
+
|
|
113
|
+
The body is decoded when the response says it is `gzip` or `deflate`
|
|
114
|
+
encoded, unless the request set its own `Accept-Encoding`; `headers` are
|
|
115
|
+
the headers as received, so `Content-Length` counts the encoded bytes. A
|
|
116
|
+
body that ends before the length the server stated is `OSError(EIO)`, never
|
|
117
|
+
a shorter body that reads as complete.
|
|
118
|
+
|
|
119
|
+
A transport builds one with `body` as `bytes`, or as an object with
|
|
120
|
+
`read(amount) -> bytes` (empty at the end) and `close()`, and
|
|
121
|
+
`decode=False` for a body it already decoded or that was not asked for
|
|
122
|
+
in a coding."""
|
|
123
|
+
|
|
124
|
+
status: int
|
|
125
|
+
reason: str
|
|
126
|
+
headers: Headers
|
|
127
|
+
url: str
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
status: int,
|
|
132
|
+
reason: str,
|
|
133
|
+
headers: "_ty.Mapping[str, str] | _ty.Iterable[tuple[str, str]]",
|
|
134
|
+
url: str,
|
|
135
|
+
body: "bytes | _ty.Any" = b"",
|
|
136
|
+
*,
|
|
137
|
+
decode: bool = True,
|
|
138
|
+
):
|
|
139
|
+
self._closed = False
|
|
140
|
+
self._ended = False
|
|
141
|
+
self.status = int(status)
|
|
142
|
+
self.reason = reason or ""
|
|
143
|
+
self.headers = headers if isinstance(headers, Headers) else Headers(headers)
|
|
144
|
+
self.url = url
|
|
145
|
+
self._kept = False
|
|
146
|
+
if isinstance(body, (bytes, bytearray, memoryview)):
|
|
147
|
+
body = _Memory(body)
|
|
148
|
+
self._kept = True # in memory: readable after close
|
|
149
|
+
self._source = body
|
|
150
|
+
self._inflater = _inflater_for(self.headers) if decode else None
|
|
151
|
+
self._leftover = b""
|
|
152
|
+
|
|
153
|
+
def __repr__(self) -> str:
|
|
154
|
+
return f"HTTPResponse({self.status}, {self.reason!r})"
|
|
155
|
+
|
|
156
|
+
def __enter__(self) -> "HTTPResponse":
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __exit__(self, *exc_info) -> None:
|
|
160
|
+
self.close()
|
|
161
|
+
|
|
162
|
+
def __del__(self):
|
|
163
|
+
try:
|
|
164
|
+
self.close()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
def _check_open(self) -> None:
|
|
169
|
+
if self._closed and not self._kept:
|
|
170
|
+
raise ValueError("read of a closed response")
|
|
171
|
+
|
|
172
|
+
def _next(self, limit: int = CHUNK) -> bytes:
|
|
173
|
+
"""The next decoded bytes of the body; empty at its end. Without a
|
|
174
|
+
coding, no more than `limit` bytes are asked of the transport."""
|
|
175
|
+
if self._ended:
|
|
176
|
+
return b""
|
|
177
|
+
self._check_open()
|
|
178
|
+
inflater = self._inflater
|
|
179
|
+
while True:
|
|
180
|
+
if inflater is not None and inflater.has_input:
|
|
181
|
+
out = inflater.step()
|
|
182
|
+
if out:
|
|
183
|
+
return out
|
|
184
|
+
continue
|
|
185
|
+
out = self._source.read(CHUNK if inflater else min(CHUNK, limit))
|
|
186
|
+
if not out:
|
|
187
|
+
if inflater is not None:
|
|
188
|
+
inflater.finish()
|
|
189
|
+
self._ended = True
|
|
190
|
+
return b""
|
|
191
|
+
if inflater is None:
|
|
192
|
+
return out
|
|
193
|
+
inflater.feed(out)
|
|
194
|
+
|
|
195
|
+
def read(self, size: "int | None" = -1) -> bytes:
|
|
196
|
+
"""Up to `size` bytes of the decoded body, fewer only at its end; the
|
|
197
|
+
whole rest of it when `size` is negative or `None`."""
|
|
198
|
+
self._check_open()
|
|
199
|
+
parts = []
|
|
200
|
+
have = 0
|
|
201
|
+
while size is None or size < 0 or have < size:
|
|
202
|
+
wanted = CHUNK if size is None or size < 0 else size - have
|
|
203
|
+
chunk = self._leftover or self._next(wanted)
|
|
204
|
+
if not chunk:
|
|
205
|
+
break
|
|
206
|
+
if size is not None and 0 <= size < have + len(chunk):
|
|
207
|
+
self._leftover = chunk[size - have :]
|
|
208
|
+
chunk = chunk[: size - have]
|
|
209
|
+
else:
|
|
210
|
+
self._leftover = b""
|
|
211
|
+
parts.append(chunk)
|
|
212
|
+
have += len(chunk)
|
|
213
|
+
return b"".join(parts)
|
|
214
|
+
|
|
215
|
+
def iter_bytes(self, chunk_size: int = CHUNK) -> "_ty.Iterator[bytes]":
|
|
216
|
+
"""The rest of the body in pieces of at most `chunk_size` bytes, each
|
|
217
|
+
as soon as it has arrived."""
|
|
218
|
+
self._check_open()
|
|
219
|
+
while True:
|
|
220
|
+
chunk = self._leftover or self._next(chunk_size)
|
|
221
|
+
if not chunk:
|
|
222
|
+
return
|
|
223
|
+
self._leftover = chunk[chunk_size:]
|
|
224
|
+
yield chunk[:chunk_size]
|
|
225
|
+
|
|
226
|
+
def close(self) -> None:
|
|
227
|
+
"""Releases the connection. Harmless when called again. A body that was
|
|
228
|
+
not read to its end is abandoned, and its connection is closed."""
|
|
229
|
+
if not getattr(self, "_closed", True):
|
|
230
|
+
self._closed = True
|
|
231
|
+
self._source.close()
|
|
232
|
+
|
|
233
|
+
def buffered(self) -> "HTTPResponse":
|
|
234
|
+
"""Reads the rest of the body into memory, closes the connection and
|
|
235
|
+
returns this response, which can then be read after it is closed. A
|
|
236
|
+
transport returns its response this way when `send()` is called with
|
|
237
|
+
`stream=False`."""
|
|
238
|
+
return self._buffered()
|
|
239
|
+
|
|
240
|
+
def _buffered(
|
|
241
|
+
self, limit: "int | None" = None, message: str = ""
|
|
242
|
+
) -> "HTTPResponse":
|
|
243
|
+
"""Reads the rest of the body into memory and closes the source, so the
|
|
244
|
+
response can be read after the connection has been given back. A
|
|
245
|
+
decoded body longer than `limit` bytes is `OSError(EFBIG)` with
|
|
246
|
+
`message`, and its connection is closed."""
|
|
247
|
+
try:
|
|
248
|
+
data = self.read(-1 if limit is None else limit + 1)
|
|
249
|
+
if limit is not None and len(data) > limit:
|
|
250
|
+
raise OSError(_errno.EFBIG, message or f"larger than {limit} bytes")
|
|
251
|
+
self._leftover = data
|
|
252
|
+
self._kept = True
|
|
253
|
+
finally:
|
|
254
|
+
self.close()
|
|
255
|
+
return self
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""The module-level setting of the HTTP client. Code reads it as an attribute
|
|
2
|
+
of this module, so assigning it applies at once."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
MAX_REPLY_BYTES = 8 * 1024 * 1024
|
|
7
|
+
"""Most bytes of a reply the library reads whole, after any content decoding:
|
|
8
|
+
what `HTTPClient.request(stream=False)` returns, and a reply read for what it
|
|
9
|
+
says (`read_limited()`, `json_body()`). A longer body raises `OSError(EFBIG)`
|
|
10
|
+
naming the request and closes the connection. The body of a reply nobody looks
|
|
11
|
+
at is read up to this many bytes and dropped (`drop_body()`). Raise it for a
|
|
12
|
+
document that is legitimately larger. Read at the time of each use."""
|
pathapi/http/_status.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The status of a reply, and how much of its body the library reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as _json
|
|
6
|
+
|
|
7
|
+
from . import _settings
|
|
8
|
+
from ._headers import peer_text
|
|
9
|
+
from ._response import CHUNK, HTTPResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HTTPStatusError(Exception):
|
|
13
|
+
"""A reply with an error status (4xx or 5xx). `response` is the reply;
|
|
14
|
+
`translate_errors()` turns the error into the exception `pathlib` raises."""
|
|
15
|
+
|
|
16
|
+
response: HTTPResponse
|
|
17
|
+
|
|
18
|
+
def __init__(self, response: HTTPResponse):
|
|
19
|
+
super().__init__(response.status)
|
|
20
|
+
self.response = response
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def raise_for_status(resp: HTTPResponse) -> None:
|
|
24
|
+
"""Raises `HTTPStatusError` for a reply with status 400 or above."""
|
|
25
|
+
if resp.status >= 400:
|
|
26
|
+
raise HTTPStatusError(resp)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def too_large(what: object, limit: int) -> str:
|
|
30
|
+
return (
|
|
31
|
+
f"The reply for {peer_text(what, 300)} is larger than {limit} bytes "
|
|
32
|
+
"(MAX_REPLY_BYTES)"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_limited(resp: HTTPResponse, what: object, limit: "int | None" = None) -> bytes:
|
|
37
|
+
"""The decoded body of `resp`, which must be at most `limit` bytes (default
|
|
38
|
+
`MAX_REPLY_BYTES`): `OSError(EFBIG)` naming `what` otherwise, with the
|
|
39
|
+
connection closed."""
|
|
40
|
+
limit = _settings.MAX_REPLY_BYTES if limit is None else limit
|
|
41
|
+
resp._buffered(limit, too_large(what, limit))
|
|
42
|
+
return resp.read()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def drop_body(resp: HTTPResponse, limit: "int | None" = None) -> None:
|
|
46
|
+
"""Gives up the body of a reply nobody looks at: it is read, up to `limit`
|
|
47
|
+
bytes (default `MAX_REPLY_BYTES`), so the connection can serve the next
|
|
48
|
+
request, and `resp` is closed (which closes the connection when the body
|
|
49
|
+
was longer). A failure while reading it is not the caller's concern: the
|
|
50
|
+
status was already used."""
|
|
51
|
+
remaining = _settings.MAX_REPLY_BYTES if limit is None else limit
|
|
52
|
+
try:
|
|
53
|
+
while remaining > 0:
|
|
54
|
+
chunk = resp.read(min(CHUNK, remaining))
|
|
55
|
+
if not chunk:
|
|
56
|
+
break
|
|
57
|
+
remaining -= len(chunk)
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
pass
|
|
60
|
+
finally:
|
|
61
|
+
resp.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def json_body(resp: HTTPResponse, what: object, limit: "int | None" = None):
|
|
65
|
+
"""The JSON document in the body of `resp` (at most `limit` bytes, default
|
|
66
|
+
`MAX_REPLY_BYTES`, else `OSError(EFBIG)` naming `what`); `ValueError` when
|
|
67
|
+
it is not one."""
|
|
68
|
+
return _json.loads(read_limited(resp, what, limit))
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The HTTP transports that exist, which one is the default, and what a new one
|
|
2
|
+
starts from."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os as _os
|
|
7
|
+
import typing as _ty
|
|
8
|
+
|
|
9
|
+
from ._body import prepare_body as _prepare_body
|
|
10
|
+
from ._builtin import HTTPTransport
|
|
11
|
+
from ._headers import (
|
|
12
|
+
check_body as _check_body,
|
|
13
|
+
check_headers as _check_headers,
|
|
14
|
+
check_method as _check_method,
|
|
15
|
+
check_timeout as _check_timeout,
|
|
16
|
+
check_url as _check_url,
|
|
17
|
+
has_header as _has_header,
|
|
18
|
+
wants_decoding as _wants_decoding,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
TRANSPORT_GROUP = "pathapi.http_transports"
|
|
22
|
+
"""The entry-point group of the HTTP transports. An entry's name is the
|
|
23
|
+
transport's name (`requests`) and its value names a class that is built with
|
|
24
|
+
no arguments."""
|
|
25
|
+
|
|
26
|
+
TRANSPORT_ENV = "PATHAPI_HTTP_TRANSPORT"
|
|
27
|
+
"""Names the transport an `HTTPClient` builds for itself: `builtin`, or the
|
|
28
|
+
name of one that an installed distribution declares in `TRANSPORT_GROUP`
|
|
29
|
+
(`requests`, from `pathapi-requests`). Read when that transport is first
|
|
30
|
+
needed; unset means `builtin`."""
|
|
31
|
+
|
|
32
|
+
_BUILTIN = "builtin"
|
|
33
|
+
|
|
34
|
+
# Transport -> (the distribution that provides it, its import name, what to run
|
|
35
|
+
# to get it). The name is known without the distribution, whose own entry in
|
|
36
|
+
# `TRANSPORT_GROUP` is found first.
|
|
37
|
+
_PROVIDED = {
|
|
38
|
+
"requests": (
|
|
39
|
+
"pathapi-requests",
|
|
40
|
+
"pathapi_requests",
|
|
41
|
+
"pip install pathapi-requests",
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# The methods whose request says `Content-Length: 0` when it has no body.
|
|
46
|
+
_EXPECTING_BODY = frozenset({"PATCH", "POST", "PUT"})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _declared() -> "dict[str, _ty.Any]":
|
|
50
|
+
"""The entry points of `TRANSPORT_GROUP` by name, the first declaration of
|
|
51
|
+
a name winning."""
|
|
52
|
+
import importlib.metadata as _metadata
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
found = _metadata.entry_points(group=TRANSPORT_GROUP)
|
|
56
|
+
except TypeError: # Python 3.9
|
|
57
|
+
found = _metadata.entry_points().get(TRANSPORT_GROUP, ())
|
|
58
|
+
declared: "dict[str, _ty.Any]" = {}
|
|
59
|
+
for entry in found:
|
|
60
|
+
declared.setdefault(entry.name, entry)
|
|
61
|
+
return declared
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def transport_names() -> "tuple[str, ...]":
|
|
65
|
+
"""The names of the HTTP transports, sorted: `builtin`, every entry of
|
|
66
|
+
`TRANSPORT_GROUP`, and the transports that another distribution provides
|
|
67
|
+
whether or not it is installed (`requests`). Nothing is imported."""
|
|
68
|
+
return tuple(sorted({_BUILTIN, *_declared(), *_PROVIDED}))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_transport(name: str) -> type:
|
|
72
|
+
"""The class of the transport called `name` (case-sensitive), loading its
|
|
73
|
+
entry point. `builtin` is always `HTTPTransport`: no entry point replaces
|
|
74
|
+
it.
|
|
75
|
+
|
|
76
|
+
Raises `ImportError` naming what to install for a transport that another
|
|
77
|
+
distribution provides and that is not installed, and `ValueError` naming
|
|
78
|
+
the choices for any other name."""
|
|
79
|
+
if name == _BUILTIN:
|
|
80
|
+
return HTTPTransport
|
|
81
|
+
entry = _declared().get(name) if isinstance(name, str) else None
|
|
82
|
+
if entry is not None:
|
|
83
|
+
found = entry.load()
|
|
84
|
+
if not callable(found):
|
|
85
|
+
raise TypeError(f"the HTTP transport {name!r} is not a class")
|
|
86
|
+
return found
|
|
87
|
+
if isinstance(name, str) and name in _PROVIDED:
|
|
88
|
+
distribution, package, install = _PROVIDED[name]
|
|
89
|
+
raise ImportError(
|
|
90
|
+
f"the {name!r} HTTP transport is provided by the {distribution} "
|
|
91
|
+
f"distribution: {install}",
|
|
92
|
+
name=package,
|
|
93
|
+
)
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"{name!r} is not an HTTP transport "
|
|
96
|
+
f"(expected one of {', '.join(map(repr, transport_names()))})"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def default_transport():
|
|
101
|
+
"""A new transport of the kind `PATHAPI_HTTP_TRANSPORT` names. A value
|
|
102
|
+
that is empty, differently cased or unknown is `ValueError` naming the
|
|
103
|
+
choices; one that another distribution provides and that is not installed
|
|
104
|
+
is `ImportError` naming it."""
|
|
105
|
+
value = _os.environ.get(TRANSPORT_ENV)
|
|
106
|
+
kind = _BUILTIN if value is None else value
|
|
107
|
+
if kind == _BUILTIN:
|
|
108
|
+
return HTTPTransport()
|
|
109
|
+
try:
|
|
110
|
+
transport_class = get_transport(kind)
|
|
111
|
+
except ValueError:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"{TRANSPORT_ENV}={kind!r} is not an HTTP transport "
|
|
114
|
+
f"(expected one of {', '.join(map(repr, transport_names()))})"
|
|
115
|
+
) from None
|
|
116
|
+
return transport_class()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PreparedRequest(_ty.NamedTuple):
|
|
120
|
+
"""A request that passed the checks of the transport contract.
|
|
121
|
+
|
|
122
|
+
`headers` hold text values, with the `Content-Length` of a body whose
|
|
123
|
+
length is known (`0` for a `PATCH`, `POST` or `PUT` without a body) and an
|
|
124
|
+
`Accept-Encoding` of `gzip, deflate` unless the caller named one. `body` is
|
|
125
|
+
`None`, `bytes`, an object that sends exactly the `Content-Length` bytes
|
|
126
|
+
when it is read or iterated, or an iterable of `bytes` to send chunked.
|
|
127
|
+
`timeout` is `(connect, read)`. `decode` says whether the response decodes
|
|
128
|
+
a `gzip` or `deflate` body: build it with `HTTPResponse(...,
|
|
129
|
+
decode=decode)`."""
|
|
130
|
+
|
|
131
|
+
method: str
|
|
132
|
+
url: str
|
|
133
|
+
headers: "dict[str, str]"
|
|
134
|
+
body: _ty.Any
|
|
135
|
+
timeout: "tuple[float | None, float | None]"
|
|
136
|
+
decode: bool
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def prepare_request(
|
|
140
|
+
method: str,
|
|
141
|
+
url: str,
|
|
142
|
+
*,
|
|
143
|
+
headers: "_ty.Mapping[str, str] | None",
|
|
144
|
+
body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
|
|
145
|
+
timeout: "float | tuple | None",
|
|
146
|
+
) -> PreparedRequest:
|
|
147
|
+
"""The arguments of `send()` as the contract reads them.
|
|
148
|
+
|
|
149
|
+
`method` is upper-cased. Raises `ValueError`, before anything is sent, for
|
|
150
|
+
a method or header name that is not an HTTP token, a header value or URL
|
|
151
|
+
with a control character, a URL with credentials or another scheme, a
|
|
152
|
+
`Content-Length` that is not a count of bytes or exceeds the body, and a
|
|
153
|
+
`timeout` that is not a positive number, `None` or a pair of them;
|
|
154
|
+
`TypeError` for a `str` body. None of the messages quotes the URL or a
|
|
155
|
+
header value. The fragment is dropped from `url`."""
|
|
156
|
+
method = _check_method(method)
|
|
157
|
+
_check_url(url)
|
|
158
|
+
_check_body(body)
|
|
159
|
+
timeout = _check_timeout(timeout)
|
|
160
|
+
pairs = _check_headers(headers)
|
|
161
|
+
sent = dict(pairs)
|
|
162
|
+
body, length = _prepare_body(body, pairs)
|
|
163
|
+
if length is not None:
|
|
164
|
+
sent["Content-Length"] = str(length)
|
|
165
|
+
elif not _has_header(sent, "Content-Length"):
|
|
166
|
+
if isinstance(body, (bytes, bytearray, memoryview)):
|
|
167
|
+
sent["Content-Length"] = str(memoryview(body).nbytes)
|
|
168
|
+
elif body is None and method in _EXPECTING_BODY:
|
|
169
|
+
sent["Content-Length"] = "0"
|
|
170
|
+
decode = _wants_decoding(sent.items())
|
|
171
|
+
if not _has_header(sent, "Accept-Encoding"):
|
|
172
|
+
sent["Accept-Encoding"] = "gzip, deflate"
|
|
173
|
+
return PreparedRequest(method, url.split("#", 1)[0], sent, body, timeout, decode)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Testing support: the suite that holds every HTTP transport to the same
|
|
2
|
+
promises.
|
|
3
|
+
|
|
4
|
+
This module is testing support, not part of the stable API: it is importable
|
|
5
|
+
and documented, but its contents (the test names, the fixtures, the helper
|
|
6
|
+
signatures, `MODULES` and `HELPERS`) may change in any release. `pathapi.http`
|
|
7
|
+
never imports it.
|
|
8
|
+
|
|
9
|
+
`HTTPClient` and the transport contract are tested over a transport chosen by
|
|
10
|
+
name, against loopback servers scripted at the byte level. A distribution that
|
|
11
|
+
provides a transport (see `pathapi.http.get_transport`) runs the same
|
|
12
|
+
tests over its own, with its distribution installed::
|
|
13
|
+
|
|
14
|
+
python -m pathapi.http.testing --http-transports requests -q
|
|
15
|
+
|
|
16
|
+
Everything after `python -m pathapi.http.testing` is passed to pytest,
|
|
17
|
+
which this module needs. `--http-transports` takes names that
|
|
18
|
+
`pathapi.http.transport_names()` lists, separated by commas; it defaults to
|
|
19
|
+
`builtin`. Every request goes to the loopback interface: the suite fails a test
|
|
20
|
+
that tries to reach any other host. The TLS tests need `cryptography` and skip
|
|
21
|
+
without it. This module imports nothing but the standard library: importing it
|
|
22
|
+
loads `pathapi.http` (and so `ssl`) but not `pytest`, which `run()` imports.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import pathlib as _pathlib
|
|
28
|
+
import typing as _ty
|
|
29
|
+
|
|
30
|
+
__all__ = ["HELPERS", "MODULES", "run", "suite_directory"]
|
|
31
|
+
|
|
32
|
+
MODULES = (
|
|
33
|
+
"test_client_default.py",
|
|
34
|
+
"test_client_errors.py",
|
|
35
|
+
"test_client_peer_text.py",
|
|
36
|
+
"test_client_redirect_origin.py",
|
|
37
|
+
"test_client_redirects.py",
|
|
38
|
+
"test_client_reply_limits.py",
|
|
39
|
+
"test_client_write_redirects.py",
|
|
40
|
+
"test_transport_body.py",
|
|
41
|
+
"test_transport_send.py",
|
|
42
|
+
)
|
|
43
|
+
"""The test modules that run once per transport, by file name."""
|
|
44
|
+
|
|
45
|
+
HELPERS = (
|
|
46
|
+
"conftest.py",
|
|
47
|
+
"transport_fakes.py",
|
|
48
|
+
"transport_guard.py",
|
|
49
|
+
"transport_wire.py",
|
|
50
|
+
)
|
|
51
|
+
"""The files beside them that they import: the fixtures and the loopback servers."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def suite_directory() -> _pathlib.Path:
|
|
55
|
+
"""The directory that holds `MODULES` and `HELPERS`: the copy installed
|
|
56
|
+
with the package, or `tests/transport/` of a source checkout. Raises
|
|
57
|
+
`FileNotFoundError` when neither is there."""
|
|
58
|
+
here = _pathlib.Path(__file__).resolve().parent
|
|
59
|
+
for candidate in (here / "suite", here.parents[3] / "tests" / "transport"):
|
|
60
|
+
if all((candidate / name).is_file() for name in (*MODULES, *HELPERS)):
|
|
61
|
+
return candidate
|
|
62
|
+
raise FileNotFoundError("the transport suite is not installed with pathapi")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run(arguments: "_ty.Sequence[str] | None" = None) -> int:
|
|
66
|
+
"""Runs `MODULES` with pytest and `arguments` (pytest options, such as
|
|
67
|
+
`--http-transports requests`) and returns the exit status. Raises
|
|
68
|
+
`ImportError` when pytest is not installed."""
|
|
69
|
+
try:
|
|
70
|
+
import pytest
|
|
71
|
+
except ImportError as error:
|
|
72
|
+
raise ImportError(
|
|
73
|
+
"the transport suite needs pytest: pip install pytest", name="pytest"
|
|
74
|
+
) from error
|
|
75
|
+
directory = suite_directory()
|
|
76
|
+
files = [str(directory / name) for name in MODULES]
|
|
77
|
+
# The files go first: with an option and its value ahead of them, pytest
|
|
78
|
+
# 8 reads the value as the first path and loads no `conftest.py`, so the
|
|
79
|
+
# option `--http-transports` is not known.
|
|
80
|
+
return int(pytest.main([*files, *(arguments or ())]))
|