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,134 @@
1
+ """`Authorization` belongs to the origin a request was made for. A request
2
+ asked again after a redirect (`HTTPClient.redirect()`) leaves it off when the
3
+ target is another scheme, host or port and keeps it within the origin,
4
+ whichever transport sends."""
5
+
6
+ import errno
7
+
8
+ import pytest
9
+
10
+ from pathapi.http import HTTPClient, HTTPTransport
11
+ from transport_wire import Wire, make_authority, response
12
+
13
+ pytestmark = pytest.mark.usefixtures("http_transport_kind")
14
+
15
+ TOKEN = "Bearer SECRET-TOKEN"
16
+
17
+
18
+ def sees_no_credential(wire):
19
+ return [r.header("Authorization") for r in wire.requests] == [None] * len(
20
+ wire.requests
21
+ )
22
+
23
+
24
+ def first_answer(client, method, url, **kwargs):
25
+ """The redirect that `url` answers, unfollowed."""
26
+ answer = client.request(method, url, allow_redirects=False, **kwargs)
27
+ assert answer.status in (301, 302)
28
+ answer.close()
29
+ return answer
30
+
31
+
32
+ @pytest.fixture
33
+ def origins():
34
+ """`home` redirects every HEAD and GET to `away`; both answer a file of
35
+ five bytes."""
36
+ with Wire(
37
+ lambda r: response(200, {"Content-Type": "text/plain"}, b"hello")
38
+ ) as away:
39
+
40
+ def home_script(request):
41
+ return response(302, {"Location": away.url + "/elsewhere"}, reason="Found")
42
+
43
+ with Wire(home_script) as home:
44
+ yield home, away
45
+
46
+
47
+ @pytest.mark.parametrize("method", ["GET", "HEAD"])
48
+ @pytest.mark.parametrize("how", ["header", "auth"])
49
+ def test_a_request_asked_again_at_another_origin_does_not_carry_authorization(
50
+ origins, method, how
51
+ ):
52
+ home, away = origins
53
+ headers = {"X-Api-Key": "K"}
54
+ auth = None
55
+ if how == "header":
56
+ headers["Authorization"] = TOKEN
57
+ else:
58
+ auth = ("user", "s3cr3t")
59
+ with HTTPClient() as client:
60
+ url = home.url + "/f"
61
+ moved = first_answer(client, method, url, headers=headers, auth=auth)
62
+ again = client.redirect(method, url, moved, headers=headers, auth=auth)
63
+ assert again.status == 200
64
+ again.close()
65
+ # The credential reached the origin it was given for, and not the other.
66
+ assert home.requests[0].header("Authorization") is not None
67
+ assert away.requests and sees_no_credential(away)
68
+ # Only Authorization is left off.
69
+ assert away.requests[0].header("X-Api-Key") == "K"
70
+ assert away.requests[0].target == "/elsewhere"
71
+
72
+
73
+ def test_a_request_asked_again_within_the_origin_keeps_authorization():
74
+ def script(request):
75
+ if request.target == "/d":
76
+ return response(301, {"Location": "/d/"}, reason="Moved")
77
+ return response(200, {"Content-Type": "text/html"}, b"<html></html>")
78
+
79
+ with Wire(script) as wire:
80
+ with HTTPClient(headers={"Authorization": TOKEN}) as client:
81
+ url = wire.url + "/d"
82
+ moved = first_answer(client, "HEAD", url)
83
+ client.redirect("HEAD", url, moved).close()
84
+ assert [r.target for r in wire.requests] == ["/d", "/d/"]
85
+ assert [r.header("Authorization") for r in wire.requests] == [TOKEN, TOKEN]
86
+
87
+
88
+ def test_a_second_redirect_back_to_the_origin_does_not_bring_it_back():
89
+ with Wire() as wire:
90
+ with Wire(lambda r: response(302, {"Location": wire.url + "/home"})) as away:
91
+
92
+ def script(request):
93
+ if request.target == "/start":
94
+ return response(302, {"Location": away.url + "/away"})
95
+ return response(200, {}, b"ok")
96
+
97
+ wire.script = script
98
+ with HTTPClient(headers={"Authorization": TOKEN}) as client:
99
+ url = wire.url + "/start"
100
+ moved = first_answer(client, "GET", url)
101
+ client.redirect("GET", url, moved).read()
102
+ assert [r.header("Authorization") for r in wire.requests] == [TOKEN, None]
103
+ assert sees_no_credential(away)
104
+
105
+
106
+ def test_a_redirect_from_https_to_plain_http_does_not_carry_authorization(tmp_path):
107
+ authority = make_authority(tmp_path, "127.0.0.1", "localhost")
108
+ with Wire(lambda r: response(200, {}, b"plain")) as plain:
109
+ secure_script = lambda r: response( # noqa: E731
110
+ 302, {"Location": plain.url + "/downgraded"}, reason="Found"
111
+ )
112
+ with Wire(secure_script, tls=authority.server_context()) as secure:
113
+ transport = HTTPTransport(ssl_context=authority.client_context())
114
+ client = HTTPClient(transport, headers={"Authorization": TOKEN})
115
+ try:
116
+ assert client.request("GET", secure.url + "/f").read() == b"plain"
117
+ finally:
118
+ transport.close()
119
+ assert secure.requests[0].header("Authorization") == TOKEN
120
+ assert plain.requests and sees_no_credential(plain)
121
+
122
+
123
+ def test_a_re_request_with_no_usable_location_is_a_refused_redirect():
124
+ with Wire(lambda r: response(302, {"Location": "ftp://127.0.0.1/x"})) as wire:
125
+ with HTTPClient() as client:
126
+ url = wire.url + "/f"
127
+ moved = first_answer(client, "GET", url)
128
+ with pytest.raises(OSError) as excinfo:
129
+ client.redirect("GET", url, moved)
130
+ assert excinfo.value.errno == errno.EIO
131
+ assert "HTTP 302" in str(excinfo.value) and "no usable Location" in str(
132
+ excinfo.value
133
+ )
134
+ assert len(wire.requests) == 1
@@ -0,0 +1,231 @@
1
+ """Redirects and credentials above a transport: the client follows a `GET`,
2
+ `HEAD` or `OPTIONS` redirect itself and takes `Authorization` off when the
3
+ scheme, host or port changes, whichever transport sends the requests."""
4
+
5
+ import errno
6
+
7
+ import pytest
8
+
9
+ from pathapi.http import HTTPClient
10
+ from transport_fakes import FakeTransport, reply
11
+ from transport_wire import Reply, Wire, response
12
+
13
+ AUTH = {"Authorization": "Bearer TOKEN-1"}
14
+
15
+
16
+ def redirect(location, status=302):
17
+ return response(status, {"Location": location}, reason="Found")
18
+
19
+
20
+ def sent_authorization(request):
21
+ return request.header("Authorization")
22
+
23
+
24
+ # --- across a real transport -------------------------------------------------
25
+
26
+
27
+ def test_authorization_goes_with_a_redirect_that_stays_on_the_origin(http_transport):
28
+ def script(request):
29
+ return (
30
+ redirect("/final") if request.target == "/start" else response(body=b"ok")
31
+ )
32
+
33
+ with Wire(script) as wire:
34
+ client = HTTPClient(http_transport, headers=AUTH)
35
+ reply_ = client.request("GET", wire.url + "/start")
36
+ assert reply_.read() == b"ok"
37
+ assert [sent_authorization(r) for r in wire.requests] == ["Bearer TOKEN-1"] * 2
38
+
39
+
40
+ def test_authorization_is_dropped_by_a_redirect_to_another_port(http_transport):
41
+ with Wire() as elsewhere:
42
+
43
+ def script(request):
44
+ return redirect(elsewhere.url + "/landing")
45
+
46
+ with Wire(script) as wire:
47
+ client = HTTPClient(http_transport, headers={**AUTH, "X-Api-Key": "K"})
48
+ client.request("GET", wire.url + "/start").read()
49
+ assert sent_authorization(wire.requests[0]) == "Bearer TOKEN-1"
50
+ (landing,) = elsewhere.requests
51
+ assert landing.target == "/landing"
52
+ assert sent_authorization(landing) is None
53
+ # Only Authorization is dropped.
54
+ assert landing.header("X-Api-Key") == "K"
55
+
56
+
57
+ def test_basic_credentials_are_dropped_by_a_redirect_like_any_authorization(
58
+ http_transport,
59
+ ):
60
+ with Wire() as elsewhere:
61
+ with Wire(lambda r: redirect(elsewhere.url + "/landing")) as wire:
62
+ client = HTTPClient(http_transport)
63
+ client.request("GET", wire.url + "/s", auth=("alice", "s3cr3t")).read()
64
+ assert sent_authorization(wire.requests[0]).startswith("Basic ")
65
+ assert sent_authorization(elsewhere.requests[0]) is None
66
+
67
+
68
+ def test_authorization_does_not_come_back_when_a_redirect_returns_to_the_origin(
69
+ http_transport,
70
+ ):
71
+ with Wire() as wire:
72
+ with Wire(lambda r: redirect(wire.url + "/back")) as away:
73
+
74
+ def script(request):
75
+ if request.target == "/start":
76
+ return redirect(away.url + "/away")
77
+ return response(body=b"home")
78
+
79
+ wire.script = script
80
+ client = HTTPClient(http_transport, headers=AUTH)
81
+ client.request("GET", wire.url + "/start").read()
82
+ assert sent_authorization(wire.requests[0]) == "Bearer TOKEN-1"
83
+ assert sent_authorization(wire.requests[1]) is None
84
+
85
+
86
+ def test_a_303_becomes_a_get_and_a_307_keeps_the_method(http_transport):
87
+ def script(request):
88
+ if request.target == "/see":
89
+ return redirect("/there", 303)
90
+ if request.target == "/again":
91
+ return redirect("/there", 307)
92
+ return response(body=b"there")
93
+
94
+ with Wire(script) as wire:
95
+ client = HTTPClient(http_transport)
96
+ client.request("OPTIONS", wire.url + "/see").read()
97
+ client.request("OPTIONS", wire.url + "/again").read()
98
+ assert [(r.method, r.target) for r in wire.requests] == [
99
+ ("OPTIONS", "/see"),
100
+ ("GET", "/there"),
101
+ ("OPTIONS", "/again"),
102
+ ("OPTIONS", "/there"),
103
+ ]
104
+
105
+
106
+ def test_a_redirect_loop_stops_after_thirty_redirects(http_transport):
107
+ with Wire(lambda r: redirect("/loop")) as wire:
108
+ client = HTTPClient(http_transport)
109
+ with pytest.raises(OSError) as excinfo:
110
+ client.request("GET", wire.url + "/loop")
111
+ assert len(wire.requests) == 31
112
+ assert "30 redirects" in str(excinfo.value)
113
+
114
+
115
+ @pytest.mark.parametrize(
116
+ "location",
117
+ [
118
+ "/a\x01b",
119
+ "ftp://127.0.0.1/x",
120
+ "file:///etc/hosts",
121
+ "javascript:alert(1)",
122
+ ],
123
+ )
124
+ def test_a_location_that_is_not_an_http_url_is_a_refused_redirect(
125
+ http_transport, location
126
+ ):
127
+ with Wire(lambda r: redirect(location)) as wire:
128
+ client = HTTPClient(http_transport)
129
+ with pytest.raises(OSError) as excinfo:
130
+ client.request("GET", wire.url + "/start")
131
+ assert excinfo.value.errno == errno.EIO
132
+ assert "HTTP 302" in str(excinfo.value) and "no usable Location" in str(
133
+ excinfo.value
134
+ )
135
+ assert len(wire.requests) == 1
136
+
137
+
138
+ def test_userinfo_in_a_location_is_not_sent(http_transport):
139
+ with Wire() as elsewhere:
140
+ target = f"http://mallory:LEAKED@127.0.0.1:{elsewhere.port}/landing"
141
+ with Wire(lambda r: redirect(target)) as wire:
142
+ HTTPClient(http_transport).request("GET", wire.url + "/s").read()
143
+ assert elsewhere.requests[0].target == "/landing"
144
+ assert sent_authorization(elsewhere.requests[0]) is None
145
+
146
+
147
+ def test_allow_redirects_false_returns_the_redirect(http_transport):
148
+ with Wire(lambda r: redirect("/there")) as wire:
149
+ reply_ = HTTPClient(http_transport).request(
150
+ "GET", wire.url + "/start", allow_redirects=False
151
+ )
152
+ assert (reply_.status, reply_.headers["Location"]) == (302, "/there")
153
+ reply_.close()
154
+ assert len(wire.requests) == 1
155
+
156
+
157
+ # --- the scheme is part of the origin ----------------------------------------
158
+
159
+
160
+ def fake_redirecting(location):
161
+ def answer(method, url, **kwargs):
162
+ if url == "http://h:443/x":
163
+ return reply(302, headers={"Location": location}, url=url)
164
+ return reply(200, b"final", url=url)
165
+
166
+ return FakeTransport(handler=answer)
167
+
168
+
169
+ @pytest.mark.parametrize(
170
+ "location, kept",
171
+ [
172
+ ("https://h/x", False), # same host and port number, another scheme
173
+ ("http://h:444/x", False),
174
+ ("http://other/x", False),
175
+ ("http://h:443/y", True),
176
+ ("//h:443/y", True),
177
+ ],
178
+ )
179
+ def test_authorization_follows_only_a_redirect_that_keeps_scheme_host_and_port(
180
+ location, kept
181
+ ):
182
+ transport = fake_redirecting(location)
183
+ HTTPClient(transport, headers=AUTH).request("GET", "http://h:443/x").read()
184
+ assert len(transport.sent) == 2
185
+ assert ("Authorization" in transport.sent[1].headers) is kept
186
+
187
+
188
+ def test_a_header_with_a_line_break_is_refused_before_any_transport_call():
189
+ transport = FakeTransport(default=reply(200))
190
+ client = HTTPClient(transport, headers={"X-A": "1\r\nX-B: 2"})
191
+ with pytest.raises(ValueError):
192
+ client.request("GET", "http://h/x")
193
+ with pytest.raises(ValueError):
194
+ HTTPClient(transport).request("GET", "http://h/x", headers={"X-A": "1\n2"})
195
+ with pytest.raises(ValueError):
196
+ HTTPClient(transport).request("GET", "http://h/x\r\nX-A: 1")
197
+ assert transport.sent == []
198
+
199
+
200
+ # --- a Location the URL check refuses ----------------------------------------------
201
+
202
+ REFUSED_LOCATIONS = [
203
+ "http://127.0.0.1:99999/x", # a port out of range
204
+ "http://127.0.0.1:port/x", # a port that is not a number
205
+ "http://a\u3002\u3002b/x", # a host name with an empty label
206
+ "http://" + "\u00e9" * 70 + "/x", # a label too long to be a name
207
+ "http://[::1/x", # an unclosed address
208
+ ]
209
+
210
+
211
+ @pytest.mark.parametrize("location", REFUSED_LOCATIONS)
212
+ def test_a_location_the_url_check_refuses_is_oserror_never_valueerror(
213
+ http_transport_kind, location
214
+ ):
215
+ raw = (
216
+ "HTTP/1.1 302 Found\r\nLocation: " + location + "\r\nContent-Length: 0\r\n\r\n"
217
+ ).encode("utf-8")
218
+ with Wire(lambda r: Reply(raw)) as wire:
219
+ with HTTPClient() as client:
220
+ try:
221
+ client.request("GET", wire.url + "/f").close()
222
+ except ValueError:
223
+ raise AssertionError("a ValueError escaped") from None
224
+ except OSError as error:
225
+ # The refused reply is named by its status; `requests` reads a
226
+ # Location it cannot parse before this library sees the reply.
227
+ assert error.errno == errno.EIO
228
+ assert "HTTP 302" in str(error) or "could not be read" in str(error)
229
+ else:
230
+ raise AssertionError("the redirect was followed")
231
+ assert len(wire.requests) == 1
@@ -0,0 +1,201 @@
1
+ """How much of a reply the library reads. A reply read whole for its status or
2
+ for a small document is held to `MAX_REPLY_BYTES`, counted in DECODED bytes,
3
+ whichever transport sent it; one nobody looks at is read up to that limit and
4
+ dropped; and a connection whose reply was longer is not kept."""
5
+
6
+ import errno
7
+ import gzip
8
+
9
+ import pytest
10
+
11
+ import pathapi.http as http
12
+ from pathapi.http import (
13
+ HTTPClient,
14
+ drop_body,
15
+ json_body,
16
+ read_limited,
17
+ )
18
+ from transport_wire import Wire, response, wait_until
19
+
20
+ pytestmark = pytest.mark.usefixtures("http_transport_kind")
21
+
22
+ CAP = 64 * 1024
23
+ INFLATED = bytes(1024 * 1024) # 16 times the cap, a kilobyte on the wire
24
+ BOMB = gzip.compress(INFLATED)
25
+ CODED = {"Content-Encoding": "gzip"}
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def small_cap(monkeypatch):
30
+ monkeypatch.setattr(http, "MAX_REPLY_BYTES", CAP)
31
+
32
+
33
+ def long_reply(status=200, extra=None):
34
+ """A reply whose body is three times the limit on the wire as well."""
35
+ return response(status, extra or {}, b"x" * (3 * CAP))
36
+
37
+
38
+ def bomb(status=200, extra=None):
39
+ return response(status, {**CODED, **(extra or {})}, BOMB)
40
+
41
+
42
+ def test_the_setting_is_read_where_it_takes_effect():
43
+ from pathapi.http import _settings
44
+
45
+ assert _settings.MAX_REPLY_BYTES == CAP == http.MAX_REPLY_BYTES
46
+
47
+
48
+ def test_the_default_limit_is_eight_mebibytes(monkeypatch):
49
+ monkeypatch.undo()
50
+ assert http.MAX_REPLY_BYTES == 8 * 1024 * 1024
51
+
52
+
53
+ # --- replies nobody looks at ---------------------------------------------------------
54
+
55
+
56
+ def test_a_dropped_reply_over_the_limit_does_not_keep_its_connection():
57
+ with Wire(lambda r: long_reply(201)) as wire:
58
+ with HTTPClient() as client:
59
+ drop_body(
60
+ client.request("PUT", wire.url + "/file", data=b"data", stream=True)
61
+ )
62
+ assert wait_until(lambda: wire.closed == [0])
63
+ drop_body(
64
+ client.request("PUT", wire.url + "/file", data=b"again", stream=True)
65
+ )
66
+ assert wire.connections == 2
67
+ assert [r.body for r in wire.requests] == [b"data", b"again"]
68
+
69
+
70
+ def test_a_dropped_small_reply_is_read_so_the_connection_serves_the_next_one():
71
+ with Wire(lambda r: response(201, {}, b"x" * 1000)) as wire:
72
+ with HTTPClient() as client:
73
+ drop_body(
74
+ client.request("PUT", wire.url + "/file", data=b"one", stream=True)
75
+ )
76
+ drop_body(
77
+ client.request("PUT", wire.url + "/file", data=b"two", stream=True)
78
+ )
79
+ assert wire.connections == 1
80
+
81
+
82
+ def test_a_coded_reply_is_dropped_without_inflating_it_whole():
83
+ with Wire(lambda r: bomb(201)) as wire:
84
+ with HTTPClient() as client:
85
+ for body in (b"data", b"again"):
86
+ reply = client.request(
87
+ "PUT", wire.url + "/file", data=body, stream=True
88
+ )
89
+ drop_body(reply)
90
+ assert [r.body for r in wire.requests] == [b"data", b"again"]
91
+
92
+
93
+ def test_the_limit_of_a_dropped_reply_can_be_given_in_the_call():
94
+ with Wire(lambda r: long_reply(200)) as wire:
95
+ with HTTPClient() as client:
96
+ for _ in range(2):
97
+ reply = client.request("GET", wire.url + "/file", stream=True)
98
+ drop_body(reply, 10 * CAP)
99
+ assert wire.connections == 1
100
+
101
+
102
+ # --- replies read for what they say ----------------------------------------------------
103
+
104
+
105
+ def test_a_reply_over_the_limit_is_efbig_counted_in_decoded_bytes():
106
+ with Wire(lambda r: bomb(207)) as wire:
107
+ with HTTPClient() as client:
108
+ reply = client.request("GET", wire.url + "/dir/", stream=True)
109
+ with pytest.raises(OSError) as excinfo:
110
+ read_limited(reply, "the directory")
111
+ # A kilobyte on the wire, so a check of the stated length would pass.
112
+ assert len(BOMB) < CAP
113
+ assert excinfo.value.errno == errno.EFBIG
114
+ assert "the directory" in str(excinfo.value)
115
+ assert str(CAP) in str(excinfo.value)
116
+ assert "MAX_REPLY_BYTES" in str(excinfo.value)
117
+
118
+
119
+ def test_a_plain_reply_that_is_too_long_is_efbig_as_well_and_not_waited_for():
120
+ with Wire(lambda r: long_reply(207)) as wire:
121
+ with HTTPClient() as client:
122
+ reply = client.request("GET", wire.url + "/dir/", stream=True)
123
+ with pytest.raises(OSError) as excinfo:
124
+ read_limited(reply, "the directory")
125
+ # Past the limit, the rest of the body is not waited for.
126
+ assert wait_until(lambda: wire.closed == [0])
127
+ assert excinfo.value.errno == errno.EFBIG
128
+
129
+
130
+ def test_a_reply_within_the_limit_is_read_and_the_connection_is_kept():
131
+ with Wire(lambda r: response(207, {}, b"<multistatus/>")) as wire:
132
+ with HTTPClient() as client:
133
+ for _ in range(2):
134
+ reply = client.request("GET", wire.url + "/dir/", stream=True)
135
+ assert read_limited(reply, "the directory") == b"<multistatus/>"
136
+ assert wire.connections == 1
137
+
138
+
139
+ def test_the_limit_is_read_each_time_so_raising_it_lets_a_larger_reply_through(
140
+ monkeypatch,
141
+ ):
142
+ with Wire(lambda r: response(207, {}, b" " * (2 * CAP))) as wire:
143
+ with HTTPClient() as client:
144
+ with pytest.raises(OSError):
145
+ read_limited(client.request("GET", wire.url + "/x", stream=True), "x")
146
+ monkeypatch.setattr(http, "MAX_REPLY_BYTES", 4 * CAP)
147
+ reply = client.request("GET", wire.url + "/x", stream=True)
148
+ assert len(read_limited(reply, "x")) == 2 * CAP
149
+
150
+
151
+ def test_the_limit_of_a_read_can_be_given_in_the_call():
152
+ with Wire(lambda r: response(200, {}, b"x" * (2 * CAP))) as wire:
153
+ with HTTPClient() as client:
154
+ reply = client.request("GET", wire.url + "/x", stream=True)
155
+ assert len(read_limited(reply, "x", 4 * CAP)) == 2 * CAP
156
+
157
+
158
+ def test_a_json_reply_over_the_limit_is_efbig():
159
+ with Wire(lambda r: bomb(200, {"Content-Type": "application/json"})) as wire:
160
+ with HTTPClient() as client:
161
+ reply = client.request("GET", wire.url + "/api", stream=True)
162
+ with pytest.raises(OSError) as excinfo:
163
+ json_body(reply, "the API")
164
+ assert excinfo.value.errno == errno.EFBIG
165
+
166
+
167
+ def test_a_json_reply_within_the_limit_is_the_document():
168
+ with Wire(lambda r: response(200, {}, b'{"name": "n", "size": [1, 2]}')) as wire:
169
+ with HTTPClient() as client:
170
+ reply = client.request("GET", wire.url + "/api", stream=True)
171
+ assert json_body(reply, "the API") == {"name": "n", "size": [1, 2]}
172
+
173
+
174
+ def test_a_reply_that_is_not_json_is_a_value_error():
175
+ with Wire(lambda r: response(200, {}, b"<html>")) as wire:
176
+ with HTTPClient() as client:
177
+ reply = client.request("GET", wire.url + "/api", stream=True)
178
+ with pytest.raises(ValueError):
179
+ json_body(reply, "the API")
180
+
181
+
182
+ # --- the client's own reads --------------------------------------------------------------
183
+
184
+
185
+ def test_the_client_reads_a_reply_whole_only_up_to_the_limit(http_transport):
186
+ with Wire(lambda r: bomb(200)) as wire:
187
+ client = HTTPClient(http_transport)
188
+ with pytest.raises(OSError) as excinfo:
189
+ client.request("GET", wire.url + "/x")
190
+ assert excinfo.value.errno == errno.EFBIG
191
+ assert wire.url in str(excinfo.value)
192
+ # A caller that reads the body itself is not limited.
193
+ with client.request("GET", wire.url + "/x", stream=True) as streamed:
194
+ assert len(streamed.read()) == len(INFLATED)
195
+
196
+
197
+ def test_a_reply_within_the_limit_is_returned_read_and_closed(http_transport):
198
+ with Wire(lambda r: response(200, {}, b"abc")) as wire:
199
+ reply = HTTPClient(http_transport).request("GET", wire.url + "/x")
200
+ assert reply.read() == b"abc"
201
+ assert reply.read() == b""