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,239 @@
1
+ """A request that changes state is not redirected the way a browser does: a
2
+ 307 or 308 to the same scheme, host and port is sent once more with the same
3
+ method and body, and every other redirect is an error, whichever transport
4
+ sends the requests."""
5
+
6
+ import errno
7
+ import io
8
+
9
+ import pytest
10
+
11
+ from pathapi.http import HTTPClient
12
+ from transport_wire import Wire, response
13
+
14
+ pytestmark = pytest.mark.usefixtures("http_transport_kind")
15
+
16
+ NOT_RESENT = [301, 302, 303]
17
+ RESENT = [307, 308]
18
+
19
+
20
+ def redirect(location, status):
21
+ return response(status, {"Location": location}, reason="Moved")
22
+
23
+
24
+ def refused(excinfo, status, location):
25
+ error = excinfo.value
26
+ assert error.errno == errno.EIO
27
+ assert str(status) in str(error) and location in str(error)
28
+ assert error.__cause__ is None and error.__context__ is None
29
+
30
+
31
+ @pytest.mark.parametrize("method", ["PUT", "DELETE", "POST", "PATCH"])
32
+ @pytest.mark.parametrize("status", NOT_RESENT)
33
+ def test_a_redirect_that_is_not_307_or_308_is_an_error_and_nothing_is_resent(
34
+ method, status
35
+ ):
36
+ with Wire(lambda r: redirect("/store/f.bin", status)) as wire:
37
+ with HTTPClient() as client:
38
+ with pytest.raises(OSError) as excinfo:
39
+ client.request(method, wire.url + "/f.bin", data=b"NEW")
40
+ refused(excinfo, status, f"{wire.url}/store/f.bin")
41
+ assert [(r.method, r.target) for r in wire.requests] == [(method, "/f.bin")]
42
+
43
+
44
+ @pytest.mark.parametrize("status", [300, 304])
45
+ def test_any_other_3xx_answer_to_a_write_is_an_error(status):
46
+ with Wire(lambda r: response(status, reason="Other")) as wire:
47
+ with HTTPClient() as client:
48
+ with pytest.raises(OSError) as excinfo:
49
+ client.request("PUT", wire.url + "/f.bin", data=b"NEW")
50
+ assert excinfo.value.errno == errno.EIO and str(status) in str(excinfo.value)
51
+ assert len(wire.requests) == 1
52
+
53
+
54
+ @pytest.mark.parametrize("status", RESENT)
55
+ @pytest.mark.parametrize("style", ["relative", "absolute"])
56
+ def test_307_and_308_send_the_same_method_and_the_whole_body_again(status, style):
57
+ def script(request):
58
+ if request.target != "/moved/f.bin":
59
+ return response(201, reason="Created")
60
+ location = "/store/f.bin" if style == "relative" else f"{wire.url}/store/f.bin"
61
+ return redirect(location, status)
62
+
63
+ with Wire(script) as wire:
64
+ with HTTPClient() as client:
65
+ reply = client.request(
66
+ "PUT", wire.url + "/moved/f.bin", data=b"NEW-PAYLOAD"
67
+ )
68
+ assert reply.status == 201
69
+ assert [(r.method, r.target, r.body) for r in wire.requests] == [
70
+ ("PUT", "/moved/f.bin", b"NEW-PAYLOAD"),
71
+ ("PUT", "/store/f.bin", b"NEW-PAYLOAD"),
72
+ ]
73
+
74
+
75
+ @pytest.mark.parametrize("status", [301, 302, 303, 307, 308])
76
+ def test_a_propfind_is_sent_again_after_any_of_these_redirects(status):
77
+ def script(request):
78
+ if request.target == "/dir":
79
+ return redirect("/dir/", status)
80
+ return response(207, {}, b"<multistatus/>", reason="Multi-Status")
81
+
82
+ with Wire(script) as wire:
83
+ with HTTPClient() as client:
84
+ reply = client.request(
85
+ "PROPFIND",
86
+ wire.url + "/dir",
87
+ data=b"<propfind/>",
88
+ headers={"Depth": "0"},
89
+ )
90
+ assert reply.status == 207
91
+ assert [(r.method, r.target, r.body) for r in wire.requests] == [
92
+ ("PROPFIND", "/dir", b"<propfind/>"),
93
+ ("PROPFIND", "/dir/", b"<propfind/>"),
94
+ ]
95
+
96
+
97
+ def test_a_second_redirect_is_an_error():
98
+ def script(request):
99
+ if request.target == "/chain":
100
+ return redirect("/again", 307)
101
+ return redirect("/store/f.bin", 307)
102
+
103
+ with Wire(script) as wire:
104
+ with HTTPClient() as client:
105
+ with pytest.raises(OSError) as excinfo:
106
+ client.request("PUT", wire.url + "/chain", data=b"NEW")
107
+ refused(excinfo, 307, f"{wire.url}/store/f.bin")
108
+ assert [(r.method, r.target) for r in wire.requests] == [
109
+ ("PUT", "/chain"),
110
+ ("PUT", "/again"),
111
+ ]
112
+
113
+
114
+ @pytest.mark.parametrize(
115
+ "target",
116
+ [
117
+ "http://localhost:{port}/store/f.bin",
118
+ "http://127.0.0.1:1/store/f.bin",
119
+ "https://127.0.0.1:{port}/store/f.bin",
120
+ "//localhost:{port}/store/f.bin",
121
+ ],
122
+ ids=["other-host", "other-port", "other-scheme", "other-host-no-scheme"],
123
+ )
124
+ def test_307_to_another_scheme_host_or_port_is_not_followed(target):
125
+ with Wire(lambda r: response()) as wire:
126
+ target = target.format(port=wire.port)
127
+ wire.script = lambda r: redirect(target, 307)
128
+ with HTTPClient() as client:
129
+ with pytest.raises(OSError) as excinfo:
130
+ client.request(
131
+ "PUT",
132
+ wire.url + "/away/f.bin",
133
+ data=b"NEW",
134
+ headers={"Authorization": "Bearer SECRET"},
135
+ )
136
+ expected = "http:" + target if target.startswith("//") else target
137
+ refused(excinfo, 307, expected)
138
+ assert [r.target for r in wire.requests] == ["/away/f.bin"]
139
+
140
+
141
+ def test_a_307_without_a_location_is_an_error():
142
+ with Wire(lambda r: response(307, reason="Temporary Redirect")) as wire:
143
+ with HTTPClient() as client:
144
+ with pytest.raises(OSError) as excinfo:
145
+ client.request("PUT", wire.url + "/nowhere", data=b"NEW")
146
+ assert excinfo.value.errno == errno.EIO and "307" in str(excinfo.value)
147
+ assert len(wire.requests) == 1
148
+
149
+
150
+ def test_a_refused_redirect_never_shows_the_userinfo_of_its_location():
151
+ with Wire() as wire:
152
+ target = f"http://user:hunter2@127.0.0.1:{wire.port}/store/f.bin"
153
+ wire.script = lambda r: redirect(target, 301)
154
+ with HTTPClient() as client:
155
+ with pytest.raises(OSError) as excinfo:
156
+ client.request("PUT", wire.url + "/leak", data=b"NEW")
157
+ refused(excinfo, 301, f"127.0.0.1:{wire.port}/store/f.bin")
158
+ assert "hunter2" not in str(excinfo.value) and "user:" not in str(excinfo.value)
159
+
160
+
161
+ def test_a_followed_redirect_ignores_the_userinfo_of_its_location():
162
+ with Wire() as wire:
163
+ target = f"http://user:hunter2@127.0.0.1:{wire.port}/store/f.bin"
164
+
165
+ def script(request):
166
+ if request.target == "/leak":
167
+ return redirect(target, 307)
168
+ return response(201, reason="Created")
169
+
170
+ wire.script = script
171
+ with HTTPClient() as client:
172
+ client.request("PUT", wire.url + "/leak", data=b"NEW")
173
+ assert [r.target for r in wire.requests] == ["/leak", "/store/f.bin"]
174
+ assert all(r.header("Authorization") is None for r in wire.requests)
175
+
176
+
177
+ def test_a_resent_request_keeps_the_credentials_of_its_origin():
178
+ def script(request):
179
+ if request.target == "/r307":
180
+ return redirect("/store/f.bin", 307)
181
+ return response(201, reason="Created")
182
+
183
+ with Wire(script) as wire:
184
+ with HTTPClient() as client:
185
+ client.request(
186
+ "PUT", wire.url + "/r307", data=b"NEW", auth=("user", "hunter2")
187
+ )
188
+ first, second = wire.requests
189
+ assert first.header("Authorization") == second.header("Authorization")
190
+ assert first.header("Authorization").startswith("Basic ")
191
+
192
+
193
+ def test_a_refused_redirect_names_the_url_without_a_query_or_fragment():
194
+ with Wire(lambda r: redirect("/store/f.bin?token=hunter2", 302)) as wire:
195
+ with HTTPClient() as client:
196
+ with pytest.raises(OSError) as excinfo:
197
+ client.request("PUT", wire.url + "/f.bin?key=hunter3", data=b"NEW")
198
+ assert "hunter2" not in str(excinfo.value) and "hunter3" not in str(excinfo.value)
199
+ assert f"{wire.url}/f.bin" in str(excinfo.value)
200
+
201
+
202
+ def test_allow_redirects_does_not_make_a_write_follow_a_redirect():
203
+ with Wire(lambda r: redirect("/store/f.bin", 302)) as wire:
204
+ with HTTPClient() as client:
205
+ with pytest.raises(OSError):
206
+ client.request(
207
+ "PUT", wire.url + "/f.bin", data=b"NEW", allow_redirects=True
208
+ )
209
+ assert [r.method for r in wire.requests] == ["PUT"]
210
+
211
+
212
+ def test_a_body_that_cannot_be_sent_twice_is_not_resent_after_a_307():
213
+ with Wire(lambda r: redirect("/store/f.bin", 307)) as wire:
214
+ with HTTPClient() as client:
215
+ with pytest.raises(OSError) as excinfo:
216
+ client.request("PUT", wire.url + "/r307", data=io.BytesIO(b"abc"))
217
+ assert excinfo.value.errno == errno.EIO
218
+ assert [r.target for r in wire.requests] == ["/r307"]
219
+
220
+
221
+ def test_a_read_still_follows_a_redirect():
222
+ def script(request):
223
+ if request.target == "/r302":
224
+ return redirect("/store/f.bin", 302)
225
+ return response(body=b"CONTENT")
226
+
227
+ with Wire(script) as wire:
228
+ with HTTPClient() as client:
229
+ assert client.request("GET", wire.url + "/r302").read() == b"CONTENT"
230
+ assert [r.method for r in wire.requests] == ["GET", "GET"]
231
+
232
+
233
+ def test_a_write_answered_by_a_redirect_that_is_not_followed_is_never_returned():
234
+ # A 3xx the client does not follow must not come back as an answer that
235
+ # looks like a finished write.
236
+ with Wire(lambda r: response(302, {"Location": "/x"}, reason="Found")) as wire:
237
+ with HTTPClient() as client:
238
+ with pytest.raises(OSError):
239
+ client.request("DELETE", wire.url + "/f.bin")
@@ -0,0 +1,302 @@
1
+ """How a request body is framed, whichever transport sends it. A body whose
2
+ length is known without reading it goes under a `Content-Length`; one whose
3
+ length is not goes chunked; a `Content-Length` the caller set is trusted; and
4
+ a body never sends more or fewer bytes than the length it was sent under. Each
5
+ test reads what the loopback server received."""
6
+
7
+ import io
8
+
9
+ import pytest
10
+
11
+ from transport_wire import Drop, Wire, response
12
+ from pathapi.http import HTTPTransport, _body
13
+
14
+ TIMEOUT = (5, 5)
15
+ PAYLOAD = b"0123456789abcdef"
16
+
17
+
18
+ def put(transport, wire, body, headers=None, **kwargs):
19
+ kwargs.setdefault("timeout", TIMEOUT)
20
+ return transport.send(
21
+ "PUT", wire.url + "/up", headers=headers or {}, body=body, **kwargs
22
+ ).read()
23
+
24
+
25
+ def framing(request):
26
+ """`(Content-Length, Transfer-Encoding)` as the server received them."""
27
+ return request.header("Content-Length"), request.header("Transfer-Encoding")
28
+
29
+
30
+ class Unseekable(io.RawIOBase):
31
+ """A stream that reads but cannot seek or report a size, like a pipe."""
32
+
33
+ def __init__(self, data):
34
+ self._data = io.BytesIO(data)
35
+
36
+ def readable(self):
37
+ return True
38
+
39
+ def seekable(self):
40
+ return False
41
+
42
+ def readinto(self, buffer):
43
+ chunk = self._data.read(len(buffer))
44
+ buffer[: len(chunk)] = chunk
45
+ return len(chunk)
46
+
47
+
48
+ class Growing(io.BytesIO):
49
+ """A file that gains bytes after it was measured."""
50
+
51
+ grown = False
52
+
53
+ def read(self, size=-1):
54
+ if not self.grown:
55
+ self.grown = True
56
+ here = self.tell()
57
+ self.seek(0, 2)
58
+ self.write(b"APPENDED-AFTER-THE-LENGTH-WAS-TAKEN")
59
+ self.seek(here)
60
+ return super().read(size)
61
+
62
+
63
+ class Shrinking(io.BytesIO):
64
+ """A file that ends sooner than it said it would."""
65
+
66
+ def read(self, size=-1):
67
+ return super().read(min(size, 4) if size and size > 0 else 4)
68
+
69
+ def seek(self, offset, whence=0):
70
+ position = super().seek(offset, whence)
71
+ if whence == 2:
72
+ return position + 100 # claims more than it holds
73
+ return position
74
+
75
+
76
+ # --- a length that is known: Content-Length ------------------------------------------
77
+
78
+
79
+ def test_bytes_go_under_their_length(http_transport):
80
+ with Wire() as wire:
81
+ put(http_transport, wire, PAYLOAD)
82
+ (request,) = wire.requests
83
+ assert framing(request) == (str(len(PAYLOAD)), None)
84
+ assert request.body == PAYLOAD
85
+
86
+
87
+ def test_a_list_of_bytes_goes_under_the_sum_of_its_pieces(http_transport):
88
+ with Wire() as wire:
89
+ put(http_transport, wire, [b"0123", b"4567", b"89"])
90
+ (request,) = wire.requests
91
+ assert framing(request) == ("10", None)
92
+ assert request.body == b"0123456789"
93
+
94
+
95
+ def test_a_bytes_io_at_the_start_goes_under_its_length(http_transport):
96
+ with Wire() as wire:
97
+ put(http_transport, wire, io.BytesIO(PAYLOAD))
98
+ (request,) = wire.requests
99
+ assert framing(request) == (str(len(PAYLOAD)), None)
100
+ assert request.body == PAYLOAD
101
+
102
+
103
+ def test_a_bytes_io_part_way_sends_what_is_left_under_that_length(http_transport):
104
+ source = io.BytesIO(PAYLOAD)
105
+ source.seek(6)
106
+ with Wire() as wire:
107
+ put(http_transport, wire, source)
108
+ (request,) = wire.requests
109
+ assert framing(request) == (str(len(PAYLOAD) - 6), None)
110
+ assert request.body == PAYLOAD[6:]
111
+
112
+
113
+ def test_an_open_file_goes_under_its_length(http_transport, tmp_path):
114
+ path = tmp_path / "upload.bin"
115
+ path.write_bytes(PAYLOAD)
116
+ with Wire() as wire, open(path, "rb") as source:
117
+ put(http_transport, wire, source)
118
+ (request,) = wire.requests
119
+ assert framing(request) == (str(len(PAYLOAD)), None)
120
+ assert request.body == PAYLOAD
121
+
122
+
123
+ def test_an_open_file_part_way_sends_what_is_left_under_that_length(
124
+ http_transport, tmp_path
125
+ ):
126
+ path = tmp_path / "upload.bin"
127
+ path.write_bytes(PAYLOAD)
128
+ with Wire() as wire, open(path, "rb") as source:
129
+ source.seek(10)
130
+ put(http_transport, wire, source)
131
+ (request,) = wire.requests
132
+ assert framing(request) == (str(len(PAYLOAD) - 10), None)
133
+ assert request.body == PAYLOAD[10:]
134
+
135
+
136
+ def test_a_file_that_cannot_seek_but_has_a_size_goes_under_it(http_transport, tmp_path):
137
+ path = tmp_path / "upload.bin"
138
+ path.write_bytes(PAYLOAD)
139
+
140
+ class NoSeek(io.BufferedReader):
141
+ def seekable(self):
142
+ return False
143
+
144
+ with Wire() as wire, NoSeek(io.FileIO(path)) as source:
145
+ source.read(4)
146
+ put(http_transport, wire, source)
147
+ (request,) = wire.requests
148
+ assert framing(request) == (str(len(PAYLOAD) - 4), None)
149
+ assert request.body == PAYLOAD[4:]
150
+
151
+
152
+ def test_an_empty_file_goes_under_a_length_of_zero(http_transport):
153
+ with Wire() as wire:
154
+ put(http_transport, wire, io.BytesIO(b""))
155
+ (request,) = wire.requests
156
+ assert framing(request) == ("0", None)
157
+ assert request.body == b""
158
+
159
+
160
+ def test_the_position_of_a_file_is_restored_after_it_is_measured():
161
+ source = io.BytesIO(PAYLOAD)
162
+ source.seek(5)
163
+ assert _body.body_length(source) == len(PAYLOAD) - 5
164
+ assert source.tell() == 5
165
+
166
+
167
+ # --- a length that is not known: chunked ---------------------------------------------
168
+
169
+
170
+ def test_a_stream_that_cannot_seek_is_sent_chunked(http_transport):
171
+ with Wire() as wire:
172
+ put(http_transport, wire, Unseekable(PAYLOAD))
173
+ (request,) = wire.requests
174
+ assert framing(request) == (None, "chunked")
175
+ assert request.body == PAYLOAD
176
+
177
+
178
+ def test_a_generator_is_sent_chunked(http_transport):
179
+ with Wire() as wire:
180
+ put(http_transport, wire, (piece for piece in (b"0123", b"4567", b"89")))
181
+ (request,) = wire.requests
182
+ assert framing(request) == (None, "chunked")
183
+ assert request.body == b"0123456789"
184
+
185
+
186
+ # --- a length the caller set ---------------------------------------------------------
187
+
188
+
189
+ def test_a_length_the_caller_set_is_used_and_nothing_is_chunked(http_transport):
190
+ with Wire() as wire:
191
+ put(
192
+ http_transport,
193
+ wire,
194
+ (piece for piece in (b"0123", b"4567", b"89")),
195
+ headers={"Content-Length": "10"},
196
+ )
197
+ (request,) = wire.requests
198
+ assert framing(request) == ("10", None)
199
+ assert request.body == b"0123456789"
200
+
201
+
202
+ def test_a_length_the_caller_set_cuts_a_longer_body_at_that_length(http_transport):
203
+ with Wire() as wire:
204
+ put(
205
+ http_transport,
206
+ wire,
207
+ io.BytesIO(PAYLOAD),
208
+ headers={"Content-Length": "6"},
209
+ )
210
+ put(http_transport, wire, b"next")
211
+ first, second = wire.requests
212
+ assert framing(first) == ("6", None)
213
+ assert first.body == PAYLOAD[:6]
214
+ # Nothing of the rest was sent: the next request on the connection is whole.
215
+ assert (second.method, second.target, second.body) == ("PUT", "/up", b"next")
216
+
217
+
218
+ def test_a_length_the_caller_set_on_bytes_cuts_them_there(http_transport):
219
+ with Wire() as wire:
220
+ put(http_transport, wire, PAYLOAD, headers={"content-length": "4"})
221
+ (request,) = wire.requests
222
+ assert framing(request) == ("4", None)
223
+ assert request.body == PAYLOAD[:4]
224
+
225
+
226
+ @pytest.mark.parametrize(
227
+ "body", [PAYLOAD, io.BytesIO(PAYLOAD), [PAYLOAD]], ids=["bytes", "file", "list"]
228
+ )
229
+ def test_a_length_larger_than_a_body_whose_size_is_known_is_refused(
230
+ http_transport, body
231
+ ):
232
+ with Wire() as wire:
233
+ with pytest.raises(ValueError):
234
+ put(http_transport, wire, body, headers={"Content-Length": "99"})
235
+ assert wire.connections == 0
236
+
237
+
238
+ @pytest.mark.parametrize("value", ["-1", "ten", "1.5", "", "1, 2"])
239
+ def test_a_length_that_is_not_a_count_of_bytes_is_refused(http_transport, value):
240
+ with Wire() as wire:
241
+ with pytest.raises(ValueError):
242
+ put(http_transport, wire, PAYLOAD, headers={"Content-Length": value})
243
+ assert wire.connections == 0
244
+
245
+
246
+ def test_a_generator_that_ends_before_the_length_the_caller_set_is_an_error(
247
+ http_transport,
248
+ ):
249
+ with Wire() as wire:
250
+ with pytest.raises(OSError) as excinfo:
251
+ put(
252
+ http_transport,
253
+ wire,
254
+ (piece for piece in (b"0123", b"4567")),
255
+ headers={"Content-Length": "10"},
256
+ timeout=(5, 0.5),
257
+ )
258
+ # Not a wait for the rest of the body that never comes.
259
+ assert not isinstance(excinfo.value, TimeoutError)
260
+
261
+
262
+ # --- a length that was right when it was taken ---------------------------------------
263
+
264
+
265
+ def test_a_file_that_grew_after_it_was_measured_sends_only_the_length_it_stated(
266
+ http_transport,
267
+ ):
268
+ with Wire() as wire:
269
+ put(http_transport, wire, Growing(PAYLOAD))
270
+ put(http_transport, wire, b"next")
271
+ first, second = wire.requests
272
+ assert framing(first) == (str(len(PAYLOAD)), None)
273
+ assert first.body == PAYLOAD
274
+ # Nothing past the length was sent: the next request is not preceded by it.
275
+ assert (second.method, second.target, second.body) == ("PUT", "/up", b"next")
276
+
277
+
278
+ def test_a_file_that_ends_before_the_length_it_stated_is_an_error(http_transport):
279
+ with Wire() as wire:
280
+ with pytest.raises(OSError) as excinfo:
281
+ put(http_transport, wire, Shrinking(PAYLOAD), timeout=(5, 0.5))
282
+ assert not isinstance(excinfo.value, TimeoutError)
283
+
284
+
285
+ # --- a body with a length is still sent once -----------------------------------------
286
+
287
+
288
+ def test_a_file_with_a_known_length_is_not_sent_again_on_a_dropped_connection():
289
+ def script(request):
290
+ if request.connection == 0 and request.body == b"two":
291
+ return Drop()
292
+ return response(body=b"ok")
293
+
294
+ with Wire(script) as wire:
295
+ with HTTPTransport() as transport:
296
+ put(transport, wire, io.BytesIO(b"one"))
297
+ with pytest.raises(OSError):
298
+ put(transport, wire, io.BytesIO(b"two"))
299
+ assert [(r.connection, r.body) for r in wire.requests] == [
300
+ (0, b"one"),
301
+ (0, b"two"),
302
+ ]