Skip to content

Commit 387eb4a

Browse files
authored
Merge pull request #21 from pytest-dev/chunked-encoding/1/dev
Support chunked encoding
2 parents bc0d4d6 + 1ae3d6d commit 387eb4a

File tree

3 files changed

+273
-8
lines changed

3 files changed

+273
-8
lines changed

README.rst

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,25 @@ poking around in the code itself.
7373
following attributes:
7474

7575
* ``code`` - HTTP response code (int)
76-
* ``content`` - content of next response (str)
76+
* ``content`` - content of next response (str, bytes, or iterable of either)
7777
* ``headers`` - response headers (dict)
78+
* ``chunked`` - whether to chunk-encode the response (enumeration)
7879

79-
Once these attribute are set, all subsequent requests will be answered with
80+
Once these attributes are set, all subsequent requests will be answered with
8081
these values until they are changed or the server is stopped. A more
8182
convenient way to change these is ::
8283

83-
httpserver.serve_content(content=None, code=200, headers=None)
84+
httpserver.serve_content(content=None, code=200, headers=None, chunked=pytest_localserver.http.Chunked.NO)
85+
86+
The ``chunked`` atribute or parameter can be set to
87+
88+
* ``Chunked.YES``, telling the server to always apply chunk encoding
89+
* ``Chunked.NO``, telling the server to never apply chunk encoding
90+
* ``Chunked.AUTO``, telling the server to apply chunk encoding only if
91+
the ``Transfer-Encoding`` header includes ``chunked``
92+
93+
If chunk encoding is applied, each str or bytes in ``content`` becomes one
94+
chunk in the response.
8495

8596
The server address can be found in property
8697

pytest_localserver/http.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
# This program is release under the MIT license. You can find the full text of
44
# the license in the LICENSE file.
55

6+
import enum
7+
import itertools
68
import json
79
import sys
810
import threading
911

12+
from werkzeug.datastructures import Headers
1013
from werkzeug.serving import make_server
1114
from werkzeug.wrappers import Response, Request
1215

@@ -39,6 +42,21 @@ def url(self):
3942
return '%s://%s:%i' % (proto, host, port)
4043

4144

45+
class Chunked(enum.Enum):
46+
NO = False
47+
YES = True
48+
AUTO = None
49+
50+
def __bool__(self):
51+
return bool(self.value)
52+
53+
54+
def _encode_chunk(chunk, charset):
55+
if isinstance(chunk, str):
56+
chunk = chunk.encode(charset)
57+
return '{0:x}'.format(len(chunk)).encode(charset) + b'\r\n' + chunk + b'\r\n'
58+
59+
4260
class ContentServer(WSGIServer):
4361

4462
"""
@@ -67,6 +85,7 @@ def __init__(self, host='127.0.0.1', port=0, ssl_context=None):
6785
self.show_post_vars = False
6886
self.compress = None
6987
self.requests = []
88+
self.chunked = Chunked.NO
7089

7190
def __call__(self, environ, start_response):
7291
"""
@@ -80,7 +99,21 @@ def __call__(self, environ, start_response):
8099
else:
81100
content = self.content
82101

83-
response = Response(status=self.code)
102+
if (
103+
self.chunked == Chunked.YES
104+
or (self.chunked == Chunked.AUTO and 'chunked' in self.headers.get('Transfer-encoding', ''))
105+
):
106+
# If the code below ever changes to allow setting the charset of
107+
# the Response object, the charset used here should also be changed
108+
# to match. But until that happens, use UTF-8 since it is Werkzeug's
109+
# default.
110+
charset = 'utf-8'
111+
if isinstance(content, (str, bytes)):
112+
content = (_encode_chunk(content, charset), '0\r\n\r\n')
113+
else:
114+
content = itertools.chain((_encode_chunk(item, charset) for item in content), ['0\r\n\r\n'])
115+
116+
response = Response(response=content, status=self.code)
84117
response.headers.clear()
85118
response.headers.extend(self.headers)
86119

@@ -89,21 +122,34 @@ def __call__(self, environ, start_response):
89122
# content = gzip.compress(content.encode('utf-8'))
90123
# response.content_encoding = 'gzip'
91124

92-
response.data = content
93125
return response(environ, start_response)
94126

95-
def serve_content(self, content, code=200, headers=None):
127+
def serve_content(self, content, code=200, headers=None, chunked=Chunked.NO):
96128
"""
97129
Serves string content (with specified HTTP error code) as response to
98130
all subsequent request.
99131
100132
:param content: content to be displayed
101133
:param code: HTTP status code
102134
:param headers: HTTP headers to be returned
135+
:param chunked: whether to apply chunked transfer encoding to the content
103136
"""
104-
self.content, self.code = (content, code)
137+
if not isinstance(content, (str, bytes, list, tuple)):
138+
# If content is an iterable which is not known to be a string,
139+
# bytes, or sequence, it might be something that can only be iterated
140+
# through once, in which case we need to cache it so it can be reused
141+
# to handle multiple requests.
142+
try:
143+
content = tuple(iter(content))
144+
except TypeError:
145+
# this probably means that content is not iterable, so just go
146+
# ahead in case it's some type that Response knows how to handle
147+
pass
148+
self.content = content
149+
self.code = code
150+
self.chunked = chunked
105151
if headers:
106-
self.headers = headers
152+
self.headers = Headers(headers)
107153

108154

109155
if __name__ == '__main__': # pragma: no cover

tests/test_http.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import itertools
2+
import pytest
13
import requests
24

35
from pytest_localserver import http, plugin
@@ -8,6 +10,9 @@
810
httpserver = plugin.httpserver
911

1012

13+
transfer_encoded = pytest.mark.parametrize('transfer_encoding_header', ['Transfer-encoding', 'Transfer-Encoding', 'transfer-encoding', 'TRANSFER-ENCODING'])
14+
15+
1116
def test_httpserver_funcarg(httpserver):
1217
assert isinstance(httpserver, http.ContentServer)
1318
assert httpserver.is_alive()
@@ -76,3 +81,206 @@ def test_HEAD_request(httpserver):
7681
# resp = requests.post(httpserver.url, data={'data': 'value'}, headers=headers)
7782
# assert resp.json() == {'data': 'value'}
7883
# assert resp.status_code == 200
84+
85+
86+
@pytest.mark.parametrize('chunked_flag', [http.Chunked.YES, http.Chunked.AUTO, http.Chunked.NO])
87+
def test_chunked_attribute_without_header(httpserver, chunked_flag):
88+
"""
89+
Test that passing the chunked attribute to serve_content() properly sets
90+
the chunked property of the server.
91+
"""
92+
httpserver.serve_content(
93+
('TEST!', 'test'),
94+
headers={'Content-type': 'text/plain'},
95+
chunked=chunked_flag
96+
)
97+
assert httpserver.chunked == chunked_flag
98+
99+
100+
@pytest.mark.parametrize('chunked_flag', [http.Chunked.YES, http.Chunked.AUTO, http.Chunked.NO])
101+
def test_chunked_attribute_with_header(httpserver, chunked_flag):
102+
"""
103+
Test that passing the chunked attribute to serve_content() properly sets
104+
the chunked property of the server even when the transfer-encoding header is
105+
also set.
106+
"""
107+
httpserver.serve_content(
108+
('TEST!', 'test'),
109+
headers={'Content-type': 'text/plain', 'Transfer-encoding': 'chunked'},
110+
chunked=chunked_flag
111+
)
112+
assert httpserver.chunked == chunked_flag
113+
114+
115+
@transfer_encoded
116+
@pytest.mark.parametrize('chunked_flag', [http.Chunked.YES, http.Chunked.AUTO])
117+
def test_GET_request_chunked_parameter(httpserver, transfer_encoding_header, chunked_flag):
118+
"""
119+
Test that passing YES or AUTO as the chunked parameter to serve_content()
120+
causes the response to be sent using chunking when the Transfer-encoding
121+
header is also set.
122+
"""
123+
httpserver.serve_content(
124+
('TEST!', 'test'),
125+
headers={'Content-type': 'text/plain', transfer_encoding_header: 'chunked'},
126+
chunked=chunked_flag
127+
)
128+
resp = requests.get(httpserver.url, headers={'User-Agent': 'Test method'})
129+
assert resp.text == 'TEST!test'
130+
assert resp.status_code == 200
131+
assert 'text/plain' in resp.headers['Content-type']
132+
assert 'chunked' in resp.headers['Transfer-encoding']
133+
134+
135+
@transfer_encoded
136+
@pytest.mark.parametrize('chunked_flag', [http.Chunked.YES, http.Chunked.AUTO])
137+
def test_GET_request_chunked_attribute(httpserver, transfer_encoding_header, chunked_flag):
138+
"""
139+
Test that setting the chunked attribute of httpserver to YES or AUTO
140+
causes the response to be sent using chunking when the Transfer-encoding
141+
header is also set.
142+
"""
143+
httpserver.serve_content(
144+
('TEST!', 'test'),
145+
headers={'Content-type': 'text/plain', transfer_encoding_header: 'chunked'}
146+
)
147+
httpserver.chunked = chunked_flag
148+
resp = requests.get(httpserver.url, headers={'User-Agent': 'Test method'})
149+
assert resp.text == 'TEST!test'
150+
assert resp.status_code == 200
151+
assert 'text/plain' in resp.headers['Content-type']
152+
assert 'chunked' in resp.headers['Transfer-encoding']
153+
154+
155+
@transfer_encoded
156+
def test_GET_request_not_chunked(httpserver, transfer_encoding_header):
157+
"""
158+
Test that setting the chunked attribute of httpserver to NO causes
159+
the response not to be sent using chunking even if the Transfer-encoding
160+
header is set.
161+
"""
162+
httpserver.serve_content(
163+
('TEST!', 'test'),
164+
headers={'Content-type': 'text/plain', transfer_encoding_header: 'chunked'},
165+
chunked=http.Chunked.NO
166+
)
167+
with pytest.raises(requests.exceptions.ChunkedEncodingError):
168+
resp = requests.get(httpserver.url, headers={'User-Agent': 'Test method'})
169+
170+
171+
@pytest.mark.parametrize('chunked_flag', [http.Chunked.NO, http.Chunked.AUTO])
172+
def test_GET_request_chunked_parameter_no_header(httpserver, chunked_flag):
173+
"""
174+
Test that passing NO or AUTO as the chunked parameter to serve_content()
175+
causes the response not to be sent using chunking when the Transfer-encoding
176+
header is not set.
177+
"""
178+
httpserver.serve_content(
179+
('TEST!', 'test'),
180+
headers={'Content-type': 'text/plain'},
181+
chunked=chunked_flag
182+
)
183+
resp = requests.get(httpserver.url, headers={'User-Agent': 'Test method'})
184+
assert resp.text == 'TEST!test'
185+
assert resp.status_code == 200
186+
assert 'text/plain' in resp.headers['Content-type']
187+
assert 'Transfer-encoding' not in resp.headers
188+
189+
190+
@pytest.mark.parametrize('chunked_flag', [http.Chunked.NO, http.Chunked.AUTO])
191+
def test_GET_request_chunked_attribute_no_header(httpserver, chunked_flag):
192+
"""
193+
Test that setting the chunked attribute of httpserver to NO or AUTO
194+
causes the response not to be sent using chunking when the Transfer-encoding
195+
header is not set.
196+
"""
197+
httpserver.serve_content(
198+
('TEST!', 'test'),
199+
headers={'Content-type': 'text/plain'}
200+
)
201+
httpserver.chunked = chunked_flag
202+
resp = requests.get(httpserver.url, headers={'User-Agent': 'Test method'})
203+
assert resp.text == 'TEST!test'
204+
assert resp.status_code == 200
205+
assert 'text/plain' in resp.headers['Content-type']
206+
assert 'Transfer-encoding' not in resp.headers
207+
208+
209+
def test_GET_request_chunked_no_header(httpserver):
210+
"""
211+
Test that setting the chunked attribute of httpserver to YES causes
212+
the response to be sent using chunking even if the Transfer-encoding
213+
header is not set.
214+
"""
215+
httpserver.serve_content(
216+
('TEST!', 'test'),
217+
headers={'Content-type': 'text/plain'},
218+
chunked=http.Chunked.YES
219+
)
220+
resp = requests.get(httpserver.url, headers={'User-Agent': 'Test method'})
221+
# Without the Transfer-encoding header set, requests does not undo the chunk
222+
# encoding so it comes through as "raw" chunks
223+
assert resp.text == '5\r\nTEST!\r\n4\r\ntest\r\n0\r\n\r\n'
224+
225+
226+
def _format_chunk(chunk):
227+
r = repr(chunk)
228+
if len(r) <= 40:
229+
return r
230+
else:
231+
return r[:13] + '...' + r[-14:] + ' (length {0})'.format(len(chunk))
232+
233+
234+
def _compare_chunks(expected, actual):
235+
__tracebackhide__ = True
236+
if expected != actual:
237+
message = [_format_chunk(expected) + ' != ' + _format_chunk(actual)]
238+
if type(expected) == type(actual):
239+
for i, (e, a) in enumerate(itertools.zip_longest(expected, actual, fillvalue='<end>')):
240+
if e != a:
241+
message += [
242+
' Chunks differ at index {}:'.format(i),
243+
' Expected: ' + (repr(expected[i:i+5]) + '...' if e != '<end>' else '<end>'),
244+
' Found: ' + (repr(actual[i:i+5]) + '...' if a != '<end>' else '<end>')
245+
]
246+
break
247+
pytest.fail('\n'.join(message))
248+
249+
250+
@pytest.mark.parametrize('chunk_size', [400, 499, 500, 512, 750, 1024, 4096, 8192])
251+
def test_GET_request_large_chunks(httpserver, chunk_size):
252+
"""
253+
Test that a response with large chunks comes through correctly
254+
"""
255+
body = b'0123456789abcdef' * 1024 # 16 kb total
256+
# Split body into fixed-size chunks, from https://stackoverflow.com/a/18854817/56541
257+
chunks = [body[0 + i:chunk_size + i] for i in range(0, len(body), chunk_size)]
258+
httpserver.serve_content(
259+
chunks,
260+
headers={'Content-type': 'text/plain', 'Transfer-encoding': 'chunked'},
261+
chunked=http.Chunked.YES
262+
)
263+
resp = requests.get(httpserver.url, headers={'User-Agent': 'Test method'}, stream=True)
264+
assert resp.status_code == 200
265+
text = b''
266+
for original_chunk, received_chunk in itertools.zip_longest(chunks, resp.iter_content(chunk_size=None)):
267+
_compare_chunks(original_chunk, received_chunk)
268+
text += received_chunk
269+
assert text == body
270+
assert 'chunked' in resp.headers['Transfer-encoding']
271+
272+
273+
@pytest.mark.parametrize('chunked_flag', [http.Chunked.YES, http.Chunked.AUTO])
274+
def test_GET_request_chunked_no_content_length(httpserver, chunked_flag):
275+
"""
276+
Test that a chunked response does not include a Content-length header
277+
"""
278+
httpserver.serve_content(
279+
('TEST!', 'test'),
280+
headers={'Content-type': 'text/plain', 'Transfer-encoding': 'chunked'},
281+
chunked=chunked_flag
282+
)
283+
resp = requests.get(httpserver.url, headers={'User-Agent': 'Test method'})
284+
assert resp.status_code == 200
285+
assert 'Transfer-encoding' in resp.headers
286+
assert 'Content-length' not in resp.headers

0 commit comments

Comments
 (0)