Skip to content

Commit c41270e

Browse files
committed
gh-43521: Add an opt-in gzip Content-Encoding handler to urllib.request
Add urllib.request.HTTPGzipHandler, an opt-in handler that requests gzip-compressed responses and transparently decodes them. The handler is not one of the default build_opener() handlers, so urlopen()'s behaviour is unchanged unless it is added explicitly. When the request has no Accept-Encoding header, the handler adds "Accept-Encoding: gzip"; if the response then carries "Content-Encoding: gzip", the body is decoded incrementally as it is read and the Content-Encoding and Content-Length headers, which describe the compressed body, are removed. A request that already carries an Accept-Encoding header is left untouched, so a caller that means to handle the compressed response itself still receives it. Only gzip is handled, and the zlib module is required.
1 parent 40f7fbf commit c41270e

5 files changed

Lines changed: 291 additions & 1 deletion

File tree

Doc/library/urllib.request.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,31 @@ The following classes are provided:
512512
Process HTTP error responses.
513513

514514

515+
.. class:: HTTPGzipHandler()
516+
517+
Request and transparently decode gzip-compressed responses.
518+
519+
This handler is not one of the handlers installed by :func:`build_opener`
520+
by default; pass it explicitly to opt in::
521+
522+
opener = urllib.request.build_opener(urllib.request.HTTPGzipHandler)
523+
with opener.open("http://www.example.com/") as response:
524+
body = response.read()
525+
526+
Unless the request already has an :mailheader:`Accept-Encoding` header,
527+
the handler adds ``Accept-Encoding: gzip``. If the response is then sent
528+
with ``Content-Encoding: gzip``, its body is decoded as it is read and the
529+
:mailheader:`Content-Encoding` and :mailheader:`Content-Length` headers,
530+
which describe the compressed body, are removed. If the request already
531+
carries an :mailheader:`Accept-Encoding` header, the response is returned
532+
unchanged, so a caller that asked for compressed data can decode it itself.
533+
534+
Only ``gzip`` is supported. The :mod:`zlib` module is required;
535+
constructing the handler without it raises :exc:`ImportError`.
536+
537+
.. versionadded:: next
538+
539+
515540
.. _request-objects:
516541

517542
Request Objects

Doc/whatsnew/3.16.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,16 @@ tkinter
539539
with no unit suffix to :class:`int` or :class:`float`.
540540
(Contributed by Serhiy Storchaka in :gh:`153513`.)
541541

542+
urllib.request
543+
--------------
544+
545+
* Add :class:`~urllib.request.HTTPGzipHandler`, an opt-in handler that
546+
requests gzip-compressed responses (by adding an ``Accept-Encoding: gzip``
547+
header) and transparently decodes them. It is not installed by
548+
:func:`~urllib.request.build_opener` by default; add it explicitly to opt
549+
in.
550+
(Contributed by Julian Soreavis in :gh:`43521`.)
551+
542552
xml
543553
---
544554

Lib/test/test_urllib2.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,6 +1893,149 @@ def test_invalid_closed(self):
18931893
self.assertTrue(conn.fakesock.closed, "Connection not closed")
18941894

18951895

1896+
class FakeGzipResponse:
1897+
# Minimal stand-in for the http.client.HTTPResponse that a response
1898+
# processor sees: headers plus a readable, closable body.
1899+
def __init__(self, data, headers, code=200):
1900+
self._file = io.BytesIO(data)
1901+
self.headers = headers
1902+
self.code = code
1903+
self.msg = "OK"
1904+
self.url = "http://example.com/"
1905+
self.closed = False
1906+
1907+
def read(self, amt=-1):
1908+
return self._file.read(amt)
1909+
1910+
def close(self):
1911+
self.closed = True
1912+
self._file.close()
1913+
1914+
1915+
class HTTPGzipHandlerTests(unittest.TestCase):
1916+
1917+
body = b"The quick brown fox jumps over the lazy dog.\n" * 200
1918+
1919+
def make_response(self, data, content_encoding="gzip", content_length=True):
1920+
import email.message
1921+
headers = email.message.Message()
1922+
if content_encoding is not None:
1923+
headers["Content-Encoding"] = content_encoding
1924+
if content_length:
1925+
headers["Content-Length"] = str(len(data))
1926+
headers["Content-Type"] = "text/plain"
1927+
return FakeGzipResponse(data, headers)
1928+
1929+
def gzip_bytes(self, data):
1930+
import gzip
1931+
return gzip.compress(data)
1932+
1933+
def opened_request(self, **kwargs):
1934+
h = urllib.request.HTTPGzipHandler()
1935+
req = Request("http://example.com/", **kwargs)
1936+
h.http_request(req)
1937+
return h, req
1938+
1939+
def test_not_installed_by_default(self):
1940+
# Opt-in: build_opener() does not install it, but accepts it explicitly.
1941+
o = urllib.request.build_opener()
1942+
self.assertFalse(any(isinstance(h, urllib.request.HTTPGzipHandler)
1943+
for h in o.handlers))
1944+
o = urllib.request.build_opener(urllib.request.HTTPGzipHandler)
1945+
self.assertTrue(any(isinstance(h, urllib.request.HTTPGzipHandler)
1946+
for h in o.handlers))
1947+
1948+
def test_request_adds_accept_encoding(self):
1949+
h, req = self.opened_request()
1950+
self.assertEqual(req.get_header("Accept-encoding"), "gzip")
1951+
1952+
def test_request_keeps_user_accept_encoding(self):
1953+
h, req = self.opened_request(headers={"Accept-Encoding": "identity"})
1954+
self.assertEqual(req.get_header("Accept-encoding"), "identity")
1955+
1956+
def test_response_is_decoded(self):
1957+
h, req = self.opened_request()
1958+
response = self.make_response(self.gzip_bytes(self.body))
1959+
with h.http_response(req, response) as result:
1960+
self.assertEqual(result.read(), self.body)
1961+
1962+
def test_response_headers_stripped(self):
1963+
h, req = self.opened_request()
1964+
response = self.make_response(self.gzip_bytes(self.body))
1965+
result = h.http_response(req, response)
1966+
info = result.info()
1967+
self.assertIsNone(info.get("Content-Encoding"))
1968+
self.assertIsNone(info.get("Content-Length"))
1969+
self.assertEqual(info.get("Content-Type"), "text/plain")
1970+
self.assertEqual(result.status, 200)
1971+
result.close()
1972+
1973+
def test_response_streamed_in_chunks(self):
1974+
h, req = self.opened_request()
1975+
response = self.make_response(self.gzip_bytes(self.body))
1976+
result = h.http_response(req, response)
1977+
chunks = []
1978+
while chunk := result.read(64):
1979+
self.assertLessEqual(len(chunk), 64)
1980+
chunks.append(chunk)
1981+
self.assertEqual(b"".join(chunks), self.body)
1982+
result.close()
1983+
1984+
def test_multiple_gzip_members_decoded(self):
1985+
# A response may be several concatenated gzip members; all of them
1986+
# must be decoded, not just the first.
1987+
h, req = self.opened_request()
1988+
comp = self.gzip_bytes(b"AAA") + self.gzip_bytes(b"BBB")
1989+
response = self.make_response(comp)
1990+
with h.http_response(req, response) as result:
1991+
self.assertEqual(result.read(), b"AAABBB")
1992+
1993+
def test_trailing_junk_after_member_ignored(self):
1994+
# Bytes after the final member that are not another gzip member are
1995+
# tolerated: the body decodes without error and is not corrupted.
1996+
h, req = self.opened_request()
1997+
comp = self.gzip_bytes(self.body) + b"trailing garbage"
1998+
response = self.make_response(comp)
1999+
with h.http_response(req, response) as result:
2000+
self.assertEqual(result.read(), self.body)
2001+
2002+
def test_user_accept_encoding_passes_through(self):
2003+
# A caller-supplied Accept-Encoding is left to the caller to decode:
2004+
# the gzipped response is returned untouched.
2005+
h, req = self.opened_request(headers={"Accept-Encoding": "gzip"})
2006+
response = self.make_response(self.gzip_bytes(self.body))
2007+
result = h.http_response(req, response)
2008+
self.assertIs(result, response)
2009+
self.assertEqual(result.headers.get("Content-Encoding"), "gzip")
2010+
2011+
def test_non_gzip_response_passes_through(self):
2012+
h, req = self.opened_request()
2013+
response = self.make_response(self.body, content_encoding=None)
2014+
result = h.http_response(req, response)
2015+
self.assertIs(result, response)
2016+
2017+
def test_truncated_stream_raises(self):
2018+
h, req = self.opened_request()
2019+
comp = self.gzip_bytes(self.body)
2020+
response = self.make_response(comp[:len(comp) // 2])
2021+
result = h.http_response(req, response)
2022+
with self.assertRaises(EOFError):
2023+
result.read()
2024+
result.close()
2025+
2026+
def test_close_propagates(self):
2027+
h, req = self.opened_request()
2028+
response = self.make_response(self.gzip_bytes(self.body))
2029+
result = h.http_response(req, response)
2030+
result.close()
2031+
self.assertTrue(response.closed)
2032+
2033+
def test_requires_zlib(self):
2034+
with mock.patch.object(urllib.request, "zlib", None):
2035+
with self.assertRaises(ImportError):
2036+
urllib.request.HTTPGzipHandler()
2037+
2038+
18962039
class MiscTests(unittest.TestCase):
18972040

18982041
def opener_has_handler(self, opener, handler_class):

Lib/urllib/request.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
import base64
8585
import bisect
8686
import contextlib
87+
import copy
8788
import email
8889
import hashlib
8990
import http.client
@@ -113,6 +114,11 @@
113114
else:
114115
_have_ssl = True
115116

117+
try:
118+
import zlib
119+
except ImportError:
120+
zlib = None
121+
116122
__all__ = [
117123
# Classes
118124
'Request', 'OpenerDirector', 'BaseHandler', 'HTTPDefaultErrorHandler',
@@ -122,7 +128,7 @@
122128
'HTTPBasicAuthHandler', 'ProxyBasicAuthHandler', 'AbstractDigestAuthHandler',
123129
'HTTPDigestAuthHandler', 'ProxyDigestAuthHandler', 'HTTPHandler',
124130
'FileHandler', 'FTPHandler', 'CacheFTPHandler', 'DataHandler',
125-
'UnknownHandler', 'HTTPErrorProcessor',
131+
'UnknownHandler', 'HTTPErrorProcessor', 'HTTPGzipHandler',
126132
# Functions
127133
'urlopen', 'install_opener', 'build_opener',
128134
'pathname2url', 'url2pathname', 'getproxies',
@@ -1392,6 +1398,107 @@ def http_response(self, request, response):
13921398
https_request = http_request
13931399
https_response = http_response
13941400

1401+
1402+
class _GzipReader(io.BufferedIOBase):
1403+
"""Incrementally decode a gzip Content-Encoding response body."""
1404+
1405+
def __init__(self, fp):
1406+
self._fp = fp
1407+
self._decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS)
1408+
self._buffer = b''
1409+
self._leftover = b''
1410+
self._got_data = False
1411+
self._eof = False
1412+
1413+
def readable(self):
1414+
return True
1415+
1416+
def _refill(self):
1417+
while True:
1418+
raw = self._leftover or self._fp.read(io.DEFAULT_BUFFER_SIZE)
1419+
self._leftover = b''
1420+
if not raw:
1421+
self._eof = True
1422+
data = self._decompressor.flush()
1423+
if self._got_data and not self._decompressor.eof:
1424+
raise EOFError('compressed response ended before the '
1425+
'end-of-stream marker was reached')
1426+
return data
1427+
self._got_data = True
1428+
if self._decompressor.eof:
1429+
# The previous member ended; the remaining bytes start the
1430+
# next concatenated member. Decode it with a fresh
1431+
# decompressor, and tolerate trailing bytes that are not
1432+
# another member, as the reader did before.
1433+
self._decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS)
1434+
try:
1435+
data = self._decompressor.decompress(raw)
1436+
except zlib.error:
1437+
self._eof = True
1438+
return b''
1439+
else:
1440+
data = self._decompressor.decompress(raw)
1441+
self._leftover = self._decompressor.unused_data
1442+
if data:
1443+
return data
1444+
1445+
def read(self, size=-1):
1446+
if size is None:
1447+
size = -1
1448+
while not self._eof and (size < 0 or len(self._buffer) < size):
1449+
self._buffer += self._refill()
1450+
if size < 0:
1451+
data, self._buffer = self._buffer, b''
1452+
else:
1453+
data, self._buffer = self._buffer[:size], self._buffer[size:]
1454+
return data
1455+
1456+
def close(self):
1457+
try:
1458+
fp = self._fp
1459+
if fp is not None:
1460+
self._fp = None
1461+
fp.close()
1462+
finally:
1463+
super().close()
1464+
1465+
1466+
class HTTPGzipHandler(BaseHandler):
1467+
"""Request and transparently decode gzip Content-Encoding responses.
1468+
1469+
This handler is not installed by build_opener() by default; add it
1470+
explicitly to opt in. It sends Accept-Encoding: gzip unless the request
1471+
already carries an Accept-Encoding header, and decodes the response only
1472+
when it added that header, so a caller supplying its own Accept-Encoding
1473+
still receives the raw response.
1474+
"""
1475+
1476+
def __init__(self):
1477+
if zlib is None:
1478+
raise ImportError('the zlib module is required for gzip decoding')
1479+
1480+
def http_request(self, request):
1481+
if not request.has_header('Accept-encoding'):
1482+
request.add_unredirected_header('Accept-encoding', 'gzip')
1483+
request._gzip_injected = True
1484+
return request
1485+
1486+
def http_response(self, request, response):
1487+
if (getattr(request, '_gzip_injected', False) and
1488+
response.headers.get('Content-Encoding', '').lower() == 'gzip'):
1489+
headers = copy.copy(response.headers)
1490+
del headers['Content-Encoding']
1491+
del headers['Content-Length']
1492+
result = addinfourl(_GzipReader(response), headers,
1493+
response.url, response.code)
1494+
result.msg = response.msg
1495+
return result
1496+
return response
1497+
1498+
https_request = http_request
1499+
https_response = http_response
1500+
1501+
13951502
class UnknownHandler(BaseHandler):
13961503
def unknown_open(self, req):
13971504
type = req.type
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Add :class:`urllib.request.HTTPGzipHandler`, an opt-in handler that sends an
2+
``Accept-Encoding: gzip`` request header and transparently decodes
3+
gzip-compressed responses. It is not one of the default
4+
:func:`~urllib.request.build_opener` handlers, so it must be added
5+
explicitly.

0 commit comments

Comments
 (0)