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,662 @@
|
|
|
1
|
+
"""What a transport puts on the wire and what it makes of the reply, against
|
|
2
|
+
loopback servers scripted at the byte level. The tests that hold for every
|
|
3
|
+
transport run once per transport; the built-in transport's connection
|
|
4
|
+
handling has its own tests in `test_http_pool.py`."""
|
|
5
|
+
|
|
6
|
+
import errno
|
|
7
|
+
import gzip
|
|
8
|
+
import io
|
|
9
|
+
import socket
|
|
10
|
+
import threading
|
|
11
|
+
import tracemalloc
|
|
12
|
+
import zlib
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from transport_wire import Drop, Hang, Reply, Wire, refusing_port, response
|
|
17
|
+
from pathapi.http import HTTPResponse, HTTPTransport
|
|
18
|
+
from transport_wire import wait_until
|
|
19
|
+
|
|
20
|
+
TIMEOUT = (5, 5)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def send(transport, url, method="GET", headers=None, **kwargs):
|
|
24
|
+
kwargs.setdefault("timeout", TIMEOUT)
|
|
25
|
+
return transport.send(method, url, headers=headers or {}, **kwargs)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- the request as the server receives it ------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_request_line_host_and_headers_arrive_as_given(http_transport):
|
|
32
|
+
with Wire() as wire:
|
|
33
|
+
reply = send(
|
|
34
|
+
http_transport, wire.url + "/a/b?x=1&y=%20", headers={"X-Probe": "1"}
|
|
35
|
+
)
|
|
36
|
+
assert reply.read() == b"ok"
|
|
37
|
+
(request,) = wire.requests
|
|
38
|
+
assert (request.method, request.target) == ("GET", "/a/b?x=1&y=%20")
|
|
39
|
+
assert request.header("Host") == f"127.0.0.1:{wire.port}"
|
|
40
|
+
assert request.header("X-Probe") == "1"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_an_empty_path_is_sent_as_a_slash_and_named_so_by_the_response(http_transport):
|
|
44
|
+
with Wire() as wire:
|
|
45
|
+
reply = send(http_transport, f"http://127.0.0.1:{wire.port}")
|
|
46
|
+
reply.read()
|
|
47
|
+
assert wire.requests[0].target == "/"
|
|
48
|
+
assert reply.url == f"http://127.0.0.1:{wire.port}/"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_the_fragment_is_not_sent(http_transport):
|
|
52
|
+
with Wire() as wire:
|
|
53
|
+
reply = send(http_transport, wire.url + "/p#frag")
|
|
54
|
+
reply.read()
|
|
55
|
+
assert wire.requests[0].target == "/p"
|
|
56
|
+
assert "#" not in reply.url
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_a_path_that_is_not_ascii_is_percent_encoded(http_transport):
|
|
60
|
+
with Wire() as wire:
|
|
61
|
+
send(http_transport, wire.url + "/café x").read()
|
|
62
|
+
assert wire.requests[0].target == "/caf%C3%A9%20x"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_gzip_and_deflate_are_advertised_unless_the_request_chooses(http_transport):
|
|
66
|
+
with Wire() as wire:
|
|
67
|
+
send(http_transport, wire.url + "/").read()
|
|
68
|
+
send(
|
|
69
|
+
http_transport, wire.url + "/", headers={"Accept-Encoding": "identity"}
|
|
70
|
+
).read()
|
|
71
|
+
assert wire.requests[0].header("Accept-Encoding") == "gzip, deflate"
|
|
72
|
+
assert wire.requests[1].header("Accept-Encoding") == "identity"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize("method", ["PUT", "POST", "PATCH", "PROPFIND"])
|
|
76
|
+
def test_a_bytes_body_arrives_whole_with_its_length(http_transport, method):
|
|
77
|
+
with Wire() as wire:
|
|
78
|
+
send(http_transport, wire.url + "/f", method=method, body=b"payload").read()
|
|
79
|
+
request = wire.requests[0]
|
|
80
|
+
assert request.method == method
|
|
81
|
+
assert request.body == b"payload"
|
|
82
|
+
assert request.header("Content-Length") == "7"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_a_put_without_a_body_says_so(http_transport):
|
|
86
|
+
with Wire() as wire:
|
|
87
|
+
send(http_transport, wire.url + "/f", method="PUT").read()
|
|
88
|
+
assert wire.requests[0].header("Content-Length") == "0"
|
|
89
|
+
assert wire.requests[0].body == b""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_a_file_object_and_an_iterable_are_sent_whole(http_transport):
|
|
93
|
+
with Wire() as wire:
|
|
94
|
+
send(
|
|
95
|
+
http_transport,
|
|
96
|
+
wire.url + "/f",
|
|
97
|
+
method="PUT",
|
|
98
|
+
body=io.BytesIO(b"from a file"),
|
|
99
|
+
).read()
|
|
100
|
+
send(
|
|
101
|
+
http_transport,
|
|
102
|
+
wire.url + "/g",
|
|
103
|
+
method="PUT",
|
|
104
|
+
body=(piece for piece in (b"in ", b"pieces")),
|
|
105
|
+
).read()
|
|
106
|
+
assert [r.body for r in wire.requests] == [b"from a file", b"in pieces"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_a_redirect_is_returned_and_not_followed(http_transport):
|
|
110
|
+
def script(request):
|
|
111
|
+
return response(302, {"Location": "/elsewhere"}, reason="Found")
|
|
112
|
+
|
|
113
|
+
with Wire(script) as wire:
|
|
114
|
+
reply = send(http_transport, wire.url + "/start")
|
|
115
|
+
assert (reply.status, reply.headers["location"]) == (302, "/elsewhere")
|
|
116
|
+
reply.close()
|
|
117
|
+
assert [r.target for r in wire.requests] == ["/start"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- the reply ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_status_reason_and_case_blind_read_only_headers(http_transport):
|
|
124
|
+
def script(request):
|
|
125
|
+
return (
|
|
126
|
+
b"HTTP/1.1 418 I'm a teapot\r\nX-Pot: a\r\nx-pot: b\r\n"
|
|
127
|
+
b"Content-Length: 0\r\n\r\n"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
with Wire(script) as wire:
|
|
131
|
+
reply = send(http_transport, wire.url + "/")
|
|
132
|
+
assert (reply.status, reply.reason) == (418, "I'm a teapot")
|
|
133
|
+
assert reply.headers["X-POT"] == "a, b"
|
|
134
|
+
assert reply.headers.get_all("x-pot") == ["a", "b"]
|
|
135
|
+
assert "x-Pot" in reply.headers and "missing" not in reply.headers
|
|
136
|
+
with pytest.raises(TypeError):
|
|
137
|
+
reply.headers["X-Pot"] = "c"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pytest.mark.parametrize("method, status", [("HEAD", 200), ("GET", 204), ("GET", 304)])
|
|
141
|
+
def test_a_reply_that_has_no_body_reads_as_empty(http_transport, method, status):
|
|
142
|
+
def script(request):
|
|
143
|
+
return (
|
|
144
|
+
f"HTTP/1.1 {status} X\r\nContent-Length: 12\r\n\r\n".encode()
|
|
145
|
+
if method == "HEAD"
|
|
146
|
+
else f"HTTP/1.1 {status} X\r\n\r\n".encode()
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
with Wire(script) as wire:
|
|
150
|
+
reply = send(http_transport, wire.url + "/", method=method)
|
|
151
|
+
assert reply.read() == b""
|
|
152
|
+
# The connection is free for the next request.
|
|
153
|
+
assert send(http_transport, wire.url + "/", method=method).read() == b""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_a_chunked_body_is_read_whole(http_transport):
|
|
157
|
+
def script(request):
|
|
158
|
+
return (
|
|
159
|
+
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
|
160
|
+
b"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
with Wire(script) as wire:
|
|
164
|
+
assert send(http_transport, wire.url + "/").read() == b"hello world"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_a_body_without_a_length_ends_with_the_connection(http_transport):
|
|
168
|
+
def script(request):
|
|
169
|
+
return Reply(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nuntil the end", True)
|
|
170
|
+
|
|
171
|
+
with Wire(script) as wire:
|
|
172
|
+
assert send(http_transport, wire.url + "/").read() == b"until the end"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_a_streamed_body_is_read_in_pieces_of_the_size_asked(http_transport):
|
|
176
|
+
with Wire(lambda r: response(body=b"0123456789")) as wire:
|
|
177
|
+
with send(http_transport, wire.url + "/", stream=True) as reply:
|
|
178
|
+
assert reply.read(4) == b"0123"
|
|
179
|
+
assert list(reply.iter_bytes(3)) == [b"456", b"789"]
|
|
180
|
+
assert reply.read() == b""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_a_buffered_reply_can_be_read_after_it_is_closed(http_transport):
|
|
184
|
+
with Wire(lambda r: response(body=b"kept")) as wire:
|
|
185
|
+
reply = send(http_transport, wire.url + "/")
|
|
186
|
+
reply.close()
|
|
187
|
+
assert reply.read() == b"kept"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_reading_a_streamed_reply_after_closing_it_is_an_error(http_transport):
|
|
191
|
+
with Wire(lambda r: response(body=b"x" * 100000)) as wire:
|
|
192
|
+
reply = send(http_transport, wire.url + "/", stream=True)
|
|
193
|
+
reply.read(10)
|
|
194
|
+
reply.close()
|
|
195
|
+
reply.close()
|
|
196
|
+
with pytest.raises(ValueError):
|
|
197
|
+
reply.read(10)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- content codings -----------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
PLAIN = b"the body that was compressed: " + bytes(range(256)) * 8
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _coded(raw, coding):
|
|
206
|
+
return response(headers={"Content-Encoding": coding}, body=raw)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.parametrize(
|
|
210
|
+
"coding, compress",
|
|
211
|
+
[
|
|
212
|
+
("gzip", gzip.compress),
|
|
213
|
+
("x-gzip", gzip.compress),
|
|
214
|
+
("deflate", zlib.compress),
|
|
215
|
+
(
|
|
216
|
+
"deflate",
|
|
217
|
+
lambda data: zlib.compress(data)[2:-4], # raw deflate, no zlib frame
|
|
218
|
+
),
|
|
219
|
+
],
|
|
220
|
+
ids=["gzip", "x-gzip", "deflate-zlib", "deflate-raw"],
|
|
221
|
+
)
|
|
222
|
+
def test_a_coded_body_is_decoded(http_transport, coding, compress):
|
|
223
|
+
with Wire(lambda r: _coded(compress(PLAIN), coding)) as wire:
|
|
224
|
+
assert send(http_transport, wire.url + "/").read() == PLAIN
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_concatenated_gzip_members_are_one_body(http_transport):
|
|
228
|
+
body = gzip.compress(b"first ") + gzip.compress(b"second")
|
|
229
|
+
with Wire(lambda r: _coded(body, "gzip")) as wire:
|
|
230
|
+
assert send(http_transport, wire.url + "/").read() == b"first second"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_a_body_is_not_decoded_when_the_request_chose_its_own_coding(http_transport):
|
|
234
|
+
packed = gzip.compress(PLAIN)
|
|
235
|
+
with Wire(lambda r: _coded(packed, "gzip")) as wire:
|
|
236
|
+
reply = send(
|
|
237
|
+
http_transport, wire.url + "/", headers={"Accept-Encoding": "gzip"}
|
|
238
|
+
)
|
|
239
|
+
assert reply.read() == packed
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_a_server_that_encodes_after_identity_was_asked_is_still_decoded(
|
|
243
|
+
http_transport,
|
|
244
|
+
):
|
|
245
|
+
with Wire(lambda r: _coded(gzip.compress(PLAIN), "gzip")) as wire:
|
|
246
|
+
reply = send(
|
|
247
|
+
http_transport, wire.url + "/", headers={"Accept-Encoding": "identity"}
|
|
248
|
+
)
|
|
249
|
+
assert reply.read() == PLAIN
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_a_coding_that_was_not_offered_is_left_alone(http_transport):
|
|
253
|
+
with Wire(lambda r: _coded(b"\x8b\x00\x00", "br")) as wire:
|
|
254
|
+
assert send(http_transport, wire.url + "/").read() == b"\x8b\x00\x00"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_headers_stay_as_received_after_decoding(http_transport):
|
|
258
|
+
packed = gzip.compress(PLAIN)
|
|
259
|
+
with Wire(lambda r: _coded(packed, "gzip")) as wire:
|
|
260
|
+
reply = send(http_transport, wire.url + "/")
|
|
261
|
+
assert reply.headers["Content-Length"] == str(len(packed))
|
|
262
|
+
assert reply.headers["Content-Encoding"] == "gzip"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_corrupt_coded_data_is_an_io_error(http_transport):
|
|
266
|
+
with Wire(lambda r: _coded(b"not gzip at all, only text", "gzip")) as wire:
|
|
267
|
+
with pytest.raises(OSError) as excinfo:
|
|
268
|
+
send(http_transport, wire.url + "/").read()
|
|
269
|
+
assert excinfo.value.errno == errno.EIO
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_a_coded_body_that_ends_early_is_an_io_error(http_transport):
|
|
273
|
+
packed = gzip.compress(PLAIN * 50)
|
|
274
|
+
with Wire(lambda r: _coded(packed[:-30], "gzip")) as wire:
|
|
275
|
+
with pytest.raises(OSError) as excinfo:
|
|
276
|
+
send(http_transport, wire.url + "/").read()
|
|
277
|
+
assert excinfo.value.errno == errno.EIO
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_a_small_body_that_inflates_enormously_is_read_in_bounded_steps(http_transport):
|
|
281
|
+
# 256 MiB of zeros packs into about 250 KiB. Reading ten bytes of it must
|
|
282
|
+
# not inflate more than a step of it.
|
|
283
|
+
packer = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
|
|
284
|
+
packed = b"".join(
|
|
285
|
+
[packer.compress(bytes(1 << 20)) for _ in range(256)] + [packer.flush()]
|
|
286
|
+
)
|
|
287
|
+
assert len(packed) < 1 << 20
|
|
288
|
+
with Wire(lambda r: _coded(packed, "gzip")) as wire:
|
|
289
|
+
reply = send(http_transport, wire.url + "/", stream=True)
|
|
290
|
+
tracemalloc.start()
|
|
291
|
+
try:
|
|
292
|
+
assert reply.read(10) == bytes(10)
|
|
293
|
+
_current, peak = tracemalloc.get_traced_memory()
|
|
294
|
+
finally:
|
|
295
|
+
tracemalloc.stop()
|
|
296
|
+
reply.close()
|
|
297
|
+
assert peak < 4 * 1024 * 1024
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --- a reply that ends early ---------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_a_body_shorter_than_its_length_is_an_io_error_not_a_short_read(http_transport):
|
|
304
|
+
def script(request):
|
|
305
|
+
return Reply(
|
|
306
|
+
b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n" + b"x" * 40, True
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
with Wire(script) as wire:
|
|
310
|
+
with pytest.raises(OSError) as excinfo:
|
|
311
|
+
send(http_transport, wire.url + "/")
|
|
312
|
+
assert excinfo.value.errno == errno.EIO
|
|
313
|
+
assert not isinstance(excinfo.value, (TimeoutError, ConnectionError))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_a_streamed_body_shorter_than_its_length_fails_when_it_runs_out(http_transport):
|
|
317
|
+
def script(request):
|
|
318
|
+
return Reply(
|
|
319
|
+
b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n" + b"x" * 40, True
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
with Wire(script) as wire:
|
|
323
|
+
with send(http_transport, wire.url + "/", stream=True) as reply:
|
|
324
|
+
assert reply.read(10) == b"x" * 10
|
|
325
|
+
with pytest.raises(OSError) as excinfo:
|
|
326
|
+
reply.read()
|
|
327
|
+
assert excinfo.value.errno == errno.EIO
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_a_chunked_body_that_stops_mid_chunk_is_an_io_error(http_transport):
|
|
331
|
+
def script(request):
|
|
332
|
+
return Reply(
|
|
333
|
+
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\nA\r\nonly",
|
|
334
|
+
True,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
with Wire(script) as wire:
|
|
338
|
+
with pytest.raises(OSError) as excinfo:
|
|
339
|
+
send(http_transport, wire.url + "/")
|
|
340
|
+
assert excinfo.value.errno == errno.EIO
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_a_connection_closed_without_a_reply_is_a_connection_error(http_transport):
|
|
344
|
+
with Wire(lambda r: Drop()) as wire:
|
|
345
|
+
with pytest.raises(ConnectionError):
|
|
346
|
+
send(http_transport, wire.url + "/")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_a_reply_that_is_not_http_is_an_io_error(http_transport):
|
|
350
|
+
with Wire(lambda r: Reply(b"this is not http at all\r\n\r\n", True)) as wire:
|
|
351
|
+
with pytest.raises(OSError) as excinfo:
|
|
352
|
+
send(http_transport, wire.url + "/")
|
|
353
|
+
assert excinfo.value.errno == errno.EIO
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_a_reply_with_more_headers_than_a_client_reads_is_an_io_error(http_transport):
|
|
357
|
+
headers = "".join(f"X-{i}: v\r\n" for i in range(500))
|
|
358
|
+
raw = f"HTTP/1.1 200 OK\r\n{headers}Content-Length: 0\r\n\r\n".encode()
|
|
359
|
+
with Wire(lambda r: Reply(raw, True)) as wire:
|
|
360
|
+
with pytest.raises(OSError) as excinfo:
|
|
361
|
+
send(http_transport, wire.url + "/")
|
|
362
|
+
assert excinfo.value.errno == errno.EIO
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_a_status_line_of_endless_length_is_an_io_error(http_transport):
|
|
366
|
+
raw = b"HTTP/1.1 200 " + b"x" * 200000 + b"\r\nContent-Length: 0\r\n\r\n"
|
|
367
|
+
with Wire(lambda r: Reply(raw, True)) as wire:
|
|
368
|
+
with pytest.raises(OSError) as excinfo:
|
|
369
|
+
send(http_transport, wire.url + "/")
|
|
370
|
+
assert excinfo.value.errno == errno.EIO
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_a_refused_connection_is_a_connection_error(http_transport):
|
|
374
|
+
with pytest.raises(ConnectionError):
|
|
375
|
+
send(http_transport, f"http://127.0.0.1:{refusing_port()}/")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# --- refused before anything is sent -------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@pytest.mark.parametrize(
|
|
382
|
+
"headers",
|
|
383
|
+
[
|
|
384
|
+
{"X-A": "1\r\nX-Injected: yes"},
|
|
385
|
+
{"X-A": "1\nX-Injected: yes"},
|
|
386
|
+
{"X-A": "1\rX-Injected: yes"},
|
|
387
|
+
{"X-A": "nul\x00byte"},
|
|
388
|
+
{"X-A\r\nX-Injected": "yes"},
|
|
389
|
+
{"X A": "space in the name"},
|
|
390
|
+
{"": "empty name"},
|
|
391
|
+
],
|
|
392
|
+
)
|
|
393
|
+
def test_a_header_with_a_line_break_or_a_bad_name_is_refused_before_sending(
|
|
394
|
+
http_transport, headers
|
|
395
|
+
):
|
|
396
|
+
with Wire() as wire:
|
|
397
|
+
with pytest.raises(ValueError):
|
|
398
|
+
send(http_transport, wire.url + "/", headers=headers)
|
|
399
|
+
assert wire.connections == 0 and wire.requests == []
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_the_refusal_of_a_header_does_not_quote_its_value(http_transport):
|
|
403
|
+
secret = "Bearer TOPSECRET-1234\r\nX-Injected: yes"
|
|
404
|
+
with Wire() as wire:
|
|
405
|
+
with pytest.raises(ValueError) as excinfo:
|
|
406
|
+
send(http_transport, wire.url + "/", headers={"Authorization": secret})
|
|
407
|
+
assert "TOPSECRET" not in str(excinfo.value)
|
|
408
|
+
assert "TOPSECRET" not in repr(excinfo.value)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@pytest.mark.parametrize(
|
|
412
|
+
"path",
|
|
413
|
+
["/a\r\nX-Injected: yes", "/a\nb", "/a\rb", "/a\x00b", "/a\tb", "/a\x7fb"],
|
|
414
|
+
)
|
|
415
|
+
def test_a_url_with_a_control_character_is_refused_before_sending(http_transport, path):
|
|
416
|
+
with Wire() as wire:
|
|
417
|
+
with pytest.raises(ValueError):
|
|
418
|
+
send(http_transport, wire.url + path)
|
|
419
|
+
assert wire.connections == 0
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@pytest.mark.parametrize(
|
|
423
|
+
"url",
|
|
424
|
+
[
|
|
425
|
+
"ftp://127.0.0.1/x",
|
|
426
|
+
"file:///etc/hosts",
|
|
427
|
+
"/relative/path",
|
|
428
|
+
"http:///no-host",
|
|
429
|
+
"http://[::1/x",
|
|
430
|
+
],
|
|
431
|
+
)
|
|
432
|
+
def test_a_url_that_is_not_an_http_url_is_refused(http_transport, url):
|
|
433
|
+
with pytest.raises(ValueError):
|
|
434
|
+
send(http_transport, url)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_credentials_in_a_url_are_refused_and_not_quoted(http_transport):
|
|
438
|
+
with Wire() as wire:
|
|
439
|
+
with pytest.raises(ValueError) as excinfo:
|
|
440
|
+
send(http_transport, f"http://alice:S3CR3T@127.0.0.1:{wire.port}/x")
|
|
441
|
+
assert wire.connections == 0
|
|
442
|
+
assert "S3CR3T" not in str(excinfo.value)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@pytest.mark.parametrize("timeout", [0, -1, "5", (1, 2, 3), True, (5, -1)])
|
|
446
|
+
def test_a_timeout_that_is_not_a_positive_number_is_refused(http_transport, timeout):
|
|
447
|
+
with Wire() as wire:
|
|
448
|
+
with pytest.raises((ValueError, TypeError)):
|
|
449
|
+
send(http_transport, wire.url + "/", timeout=timeout)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_a_body_that_is_text_is_refused(http_transport):
|
|
453
|
+
with Wire() as wire:
|
|
454
|
+
with pytest.raises(TypeError):
|
|
455
|
+
send(http_transport, wire.url + "/", method="PUT", body="text")
|
|
456
|
+
assert wire.connections == 0
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# --- timeouts ----------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@pytest.fixture
|
|
463
|
+
def socket_timeouts(monkeypatch):
|
|
464
|
+
"""Every timeout the calling thread sets on a socket, in order. What a
|
|
465
|
+
transport asks of the socket is what bounds a wait, so a test reads it
|
|
466
|
+
here instead of timing the wait."""
|
|
467
|
+
seen = []
|
|
468
|
+
caller = threading.get_ident()
|
|
469
|
+
real = socket.socket.settimeout
|
|
470
|
+
|
|
471
|
+
def settimeout(self, value):
|
|
472
|
+
if threading.get_ident() == caller:
|
|
473
|
+
seen.append(value)
|
|
474
|
+
return real(self, value)
|
|
475
|
+
|
|
476
|
+
monkeypatch.setattr(socket.socket, "settimeout", settimeout)
|
|
477
|
+
return seen
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def test_a_server_that_never_answers_is_a_timeout_after_the_read_timeout(
|
|
481
|
+
http_transport, socket_timeouts
|
|
482
|
+
):
|
|
483
|
+
with Wire(lambda r: Hang()) as wire:
|
|
484
|
+
socket_timeouts.clear() # the wire's own listener set one
|
|
485
|
+
with pytest.raises(TimeoutError):
|
|
486
|
+
send(http_transport, wire.url + "/", timeout=(5, 0.3))
|
|
487
|
+
# The server got the whole request, so the connection was made and
|
|
488
|
+
# the wait that ran out was the one for an answer: it was bounded by
|
|
489
|
+
# the read half, and the connect half was only ever asked of the
|
|
490
|
+
# connection.
|
|
491
|
+
assert [(r.method, r.target) for r in wire.requests] == [("GET", "/")]
|
|
492
|
+
assert socket_timeouts[0] == 5
|
|
493
|
+
assert set(socket_timeouts[1:]) == {0.3}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def test_a_body_that_stalls_is_a_timeout_when_it_is_read(http_transport):
|
|
497
|
+
def script(request):
|
|
498
|
+
return Reply(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\npartial")
|
|
499
|
+
|
|
500
|
+
with Wire(script) as wire:
|
|
501
|
+
reply = send(http_transport, wire.url + "/", stream=True, timeout=(5, 0.3))
|
|
502
|
+
assert reply.read(7) == b"partial"
|
|
503
|
+
with pytest.raises(TimeoutError):
|
|
504
|
+
reply.read(1)
|
|
505
|
+
reply.close()
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def test_a_single_number_is_both_halves_of_the_timeout(http_transport):
|
|
509
|
+
with Wire(lambda r: Hang()) as wire:
|
|
510
|
+
with pytest.raises(TimeoutError):
|
|
511
|
+
send(http_transport, wire.url + "/", timeout=0.3)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# --- the built-in transport's two timeouts ---------------------------------
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def test_the_connect_half_bounds_the_connection_and_the_read_half_the_waits(
|
|
518
|
+
monkeypatch,
|
|
519
|
+
):
|
|
520
|
+
from pathapi.http import _builtin
|
|
521
|
+
|
|
522
|
+
seen = {}
|
|
523
|
+
|
|
524
|
+
class Recorded:
|
|
525
|
+
"""A connected socket that notes the timeout it is given."""
|
|
526
|
+
|
|
527
|
+
def __init__(self, sock):
|
|
528
|
+
self._sock = sock
|
|
529
|
+
|
|
530
|
+
def settimeout(self, value):
|
|
531
|
+
seen["read"] = value
|
|
532
|
+
self._sock.settimeout(value)
|
|
533
|
+
|
|
534
|
+
def __getattr__(self, name):
|
|
535
|
+
return getattr(self._sock, name)
|
|
536
|
+
|
|
537
|
+
real = socket.create_connection
|
|
538
|
+
|
|
539
|
+
def create_connection(address, timeout=None, *args, **kwargs):
|
|
540
|
+
seen["connect"] = timeout
|
|
541
|
+
return Recorded(real(address, timeout, *args, **kwargs))
|
|
542
|
+
|
|
543
|
+
monkeypatch.setattr(_builtin._socket, "create_connection", create_connection)
|
|
544
|
+
with Wire() as wire, HTTPTransport() as transport:
|
|
545
|
+
send(transport, wire.url + "/", timeout=(3.5, 7.5)).read()
|
|
546
|
+
assert seen == {"connect": 3.5, "read": 7.5}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# --- credentials never in a repr or a message --------------------------------
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def test_a_transport_repr_shows_no_proxy_credentials():
|
|
553
|
+
proxies = {"http": "http://user:PROXYSECRET@proxy.example:3128"}
|
|
554
|
+
transport = HTTPTransport(proxies=proxies)
|
|
555
|
+
assert "PROXYSECRET" not in repr(transport)
|
|
556
|
+
assert "PROXYSECRET" not in str(transport)
|
|
557
|
+
transport.close()
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def test_an_http_response_repr_shows_no_header_values(http_transport):
|
|
561
|
+
with Wire(
|
|
562
|
+
lambda r: response(headers={"Set-Cookie": "session=SECRETCOOKIE"})
|
|
563
|
+
) as wire:
|
|
564
|
+
reply = send(http_transport, wire.url + "/")
|
|
565
|
+
assert "SECRETCOOKIE" not in repr(reply) + repr(reply.headers)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# --- the response object on its own ------------------------------------------
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def test_a_response_can_be_built_from_bytes_for_a_fake_transport():
|
|
572
|
+
reply = HTTPResponse(
|
|
573
|
+
200, "OK", {"Content-Type": "text/plain"}, "http://h/x", b"abc"
|
|
574
|
+
)
|
|
575
|
+
assert (reply.status, reply.reason, reply.url) == (200, "OK", "http://h/x")
|
|
576
|
+
assert reply.headers["content-type"] == "text/plain"
|
|
577
|
+
assert reply.read(2) == b"ab" and reply.read() == b"c" and reply.read() == b""
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def test_a_response_decodes_only_what_its_headers_say():
|
|
581
|
+
packed = gzip.compress(b"plain text")
|
|
582
|
+
assert HTTPResponse(200, "", {"Content-Encoding": "gzip"}, "u", packed).read() == (
|
|
583
|
+
b"plain text"
|
|
584
|
+
)
|
|
585
|
+
assert HTTPResponse(200, "", {}, "u", packed).read() == packed
|
|
586
|
+
assert (
|
|
587
|
+
HTTPResponse(
|
|
588
|
+
200, "", {"Content-Encoding": "gzip"}, "u", packed, decode=False
|
|
589
|
+
).read()
|
|
590
|
+
== packed
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# --- interim (1xx) replies ---------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
INTERIM = b"HTTP/1.1 103 Early Hints\r\nLink: </style.css>; rel=preload\r\n\r\n"
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def test_an_interim_reply_is_never_the_previous_answer_of_the_next_request(
|
|
600
|
+
http_transport,
|
|
601
|
+
):
|
|
602
|
+
def script(request):
|
|
603
|
+
if request.target == "/a":
|
|
604
|
+
return Reply(INTERIM, later=response(body=b"A"), delay=0.3)
|
|
605
|
+
return response(body=b"B")
|
|
606
|
+
|
|
607
|
+
with Wire(script) as wire:
|
|
608
|
+
try:
|
|
609
|
+
first = send(http_transport, wire.url + "/a").read()
|
|
610
|
+
except OSError:
|
|
611
|
+
first = None # a transport that cannot skip it says so
|
|
612
|
+
assert send(http_transport, wire.url + "/b").read() == b"B"
|
|
613
|
+
assert first in (None, b"A")
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def test_the_built_in_transport_skips_interim_replies_and_returns_the_final_one():
|
|
617
|
+
def script(request):
|
|
618
|
+
return Reply(INTERIM * 2, later=response(body=b"FINAL"), delay=0.2)
|
|
619
|
+
|
|
620
|
+
with Wire(script) as wire, HTTPTransport() as transport:
|
|
621
|
+
reply = send(transport, wire.url + "/")
|
|
622
|
+
assert (reply.status, reply.read()) == (200, b"FINAL")
|
|
623
|
+
# The connection is clean for the next request.
|
|
624
|
+
assert send(transport, wire.url + "/").read() == b"FINAL"
|
|
625
|
+
assert wire.connections == 1
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def test_a_bounded_number_of_interim_replies_is_skipped():
|
|
629
|
+
for count, ok in ((8, True), (9, False)):
|
|
630
|
+
|
|
631
|
+
def script(request, count=count):
|
|
632
|
+
return Reply(
|
|
633
|
+
b"HTTP/1.1 100 Continue\r\n\r\n" * count + response(body=b"done")
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
with Wire(script) as wire, HTTPTransport() as transport:
|
|
637
|
+
if ok:
|
|
638
|
+
assert send(transport, wire.url + "/").read() == b"done"
|
|
639
|
+
else:
|
|
640
|
+
with pytest.raises(OSError) as excinfo:
|
|
641
|
+
send(transport, wire.url + "/")
|
|
642
|
+
assert excinfo.value.errno == errno.EIO
|
|
643
|
+
# The connection was given up, not kept with the rest unread.
|
|
644
|
+
assert wait_until(lambda: wire.closed == [0])
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def test_an_endless_stream_of_100_continue_is_given_up_not_waited_out():
|
|
648
|
+
endless = b"HTTP/1.1 100 Continue\r\n\r\n" * 5000
|
|
649
|
+
|
|
650
|
+
with Wire(lambda request: Reply(endless)) as wire, HTTPTransport() as transport:
|
|
651
|
+
with pytest.raises(OSError) as excinfo:
|
|
652
|
+
send(transport, wire.url + "/", timeout=(2, 2))
|
|
653
|
+
# Given up for sending too many, not for the server going quiet.
|
|
654
|
+
assert excinfo.value.errno == errno.EIO
|
|
655
|
+
assert not isinstance(excinfo.value, TimeoutError)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def test_a_status_below_200_is_never_the_final_reply():
|
|
659
|
+
with Wire(lambda request: Reply(INTERIM, close=True)) as wire:
|
|
660
|
+
with HTTPTransport() as transport:
|
|
661
|
+
with pytest.raises(OSError):
|
|
662
|
+
send(transport, wire.url + "/")
|