From 8a0656c0f8b3f2e808d45c594b0d11adbe2ed961 Mon Sep 17 00:00:00 2001 From: David Bordeynik Date: Sat, 16 Jan 2021 14:53:04 +0200 Subject: [PATCH 1/2] bpo-42643: Add support for HTTP range requests --- Doc/library/http.server.rst | 4 ++ Doc/whatsnew/3.16.rst | 7 +++ Lib/http/server.py | 91 ++++++++++++++++++++++++++++++++---- Lib/test/test_httpservers.py | 76 ++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 9 deletions(-) diff --git a/Doc/library/http.server.rst b/Doc/library/http.server.rst index 0d427db19c0fcdb..6e9b22a14a68be7 100644 --- a/Doc/library/http.server.rst +++ b/Doc/library/http.server.rst @@ -392,6 +392,10 @@ instantiation, of which this module provides three different variants: .. versionchanged:: 3.15 Added *extra_response_headers* parameter. + .. versionchanged:: 3.16 + Added support for HTTP single-part range requests for files, + as specified in :rfc:`9110#section-14`. + A lot of the work, such as parsing the request, is done by the base class :class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET` and :func:`do_HEAD` functions. diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 06e831ea1e34631..a44a256373253a1 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -276,6 +276,13 @@ gzip which is passed on to the constructor of the :class:`~gzip.GzipFile` class. (Contributed by Marin Misur in :gh:`91372`.) +http.server +----------- + +* :class:`~http.server.SimpleHTTPRequestHandler` now supports HTTP + single-part range requests for files, as specified in :rfc:`9110#section-14`. + (Contributed by David Bord in :gh:`86809`.) + io -- diff --git a/Lib/http/server.py b/Lib/http/server.py index 095b5744bd12fc6..02b12aa154ccb7a 100644 --- a/Lib/http/server.py +++ b/Lib/http/server.py @@ -76,6 +76,7 @@ import mimetypes import os import posixpath +import re import shutil import socket import socketserver @@ -111,6 +112,9 @@ """ DEFAULT_ERROR_CONTENT_TYPE = "text/html;charset=utf-8" +HTTP_BYTES_RANGE_HEADER = re.compile( + r"bytes=(\d*)-(\d*)$", re.ASCII | re.IGNORECASE) + class HTTPServer(socketserver.TCPServer): @@ -758,7 +762,7 @@ def do_GET(self): f = self.send_head() if f: try: - self.copyfile(f, self.wfile) + self.copyfile(f, self.wfile, http_range=self._range) finally: f.close() @@ -791,6 +795,7 @@ def send_head(self): """ path = self.translate_path(self.path) f = None + self._range = None if os.path.isdir(path): parts = urllib.parse.urlsplit(self.path) if not parts.path.endswith(('/', '%2f', '%2F')): @@ -810,6 +815,7 @@ def send_head(self): break else: return self.list_directory(path) + self._range = self.parse_range() ctype = self.guess_type(path) # check for trailing "/" which should return 404. See Issue17324 # The test for this was added in test_httpserver.py @@ -854,10 +860,44 @@ def send_head(self): self.end_headers() f.close() return None - - self.send_response(HTTPStatus.OK) + if self._range: + start, end = self._range + if start is None and end == 0: + f.close() + self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + self.send_header("Content-Range", f"bytes */{fs.st_size}") + self.send_header("Content-Length", "0") + self.end_headers() + return None + if start is None: + start = max(0, fs.st_size - end) + end = fs.st_size - 1 + elif end is None or end >= fs.st_size: + end = fs.st_size - 1 + + if start == 0 and end >= fs.st_size - 1: + self._range = None + elif start >= fs.st_size: + f.close() + self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + self.send_header("Content-Range", f"bytes */{fs.st_size}") + self.send_header("Content-Length", "0") + self.end_headers() + return None + else: + self._range = (start, end) + + if self._range: + start, end = self._range + self.send_response(HTTPStatus.PARTIAL_CONTENT) + self.send_header("Content-Range", + f"bytes {start}-{end}/{fs.st_size}") + self.send_header("Content-Length", str(end - start + 1)) + else: + self.send_response(HTTPStatus.OK) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Length", str(fs.st_size)) self.send_header("Content-type", ctype) - self.send_header("Content-Length", str(fs[6])) self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) self._send_extra_response_headers() @@ -959,13 +999,15 @@ def translate_path(self, path): path += '/' return path - def copyfile(self, source, outputfile): + def copyfile(self, source, outputfile, *, http_range=None): """Copy all data between two file objects. + If *http_range* is provided, copy only that inclusive byte range. + The SOURCE argument is a file object open for reading - (or anything with a read() method) and the DESTINATION - argument is a file object open for writing (or - anything with a write() method). + (or anything with read() and seek() methods) and the DESTINATION + argument is a file object open for writing (or anything with a + write() method). The only reason for overriding this would be to change the block size or perhaps to replace newlines by CRLF @@ -973,7 +1015,19 @@ def copyfile(self, source, outputfile): to copy binary data as well. """ - shutil.copyfileobj(source, outputfile) + if http_range is None: + shutil.copyfileobj(source, outputfile) + return + + start, end = http_range + length = end - start + 1 + source.seek(start) + while length > 0: + buf = source.read(min(length, shutil.COPY_BUFSIZE)) + if not buf: + raise EOFError("File shrank after size was checked") + length -= len(buf) + outputfile.write(buf) def guess_type(self, path): """Guess the type of a file. @@ -1000,6 +1054,25 @@ def guess_type(self, path): return guess return self.default_content_type + def parse_range(self): + """Parse a single-part Range header into an inclusive byte range.""" + range_header = self.headers.get("Range") + if range_header is None: + return None + + match = HTTP_BYTES_RANGE_HEADER.fullmatch(range_header) + if match is None: + return None + + start = int(match.group(1)) if match.group(1) else None + end = int(match.group(2)) if match.group(2) else None + + if start is None and end is None: + return None + if start is not None and end is not None and start > end: + return None + return start, end + nobody = None diff --git a/Lib/test/test_httpservers.py b/Lib/test/test_httpservers.py index 63f65a9c5cf47a9..94c488ce8a85afc 100644 --- a/Lib/test/test_httpservers.py +++ b/Lib/test/test_httpservers.py @@ -786,6 +786,7 @@ def test_get_dir_redirect_location_domain_injection_bug(self): def test_get(self): #constructs the path relative to the root directory of the HTTPServer response = self.request(self.base_url + '/test') + self.assertEqual(response.getheader('accept-ranges'), 'bytes') self.check_status_and_reason(response, HTTPStatus.OK, data=self.data) # check for trailing "/" which should return 404. See Issue17324 response = self.request(self.base_url + '/test/') @@ -834,6 +835,81 @@ def test_get(self): finally: os.chmod(self.tempdir, 0o755) + @support.subTests( + 'range_header,content_range,content_length,start,end', + [ + ('bYtEs=2-5', 'bytes 2-5/30', '4', 2, 6), + ('bytes=3-', 'bytes 3-29/30', '27', 3, None), + ('bytes=-5', 'bytes 25-29/30', '5', 25, None), + ('bytes=29-29', 'bytes 29-29/30', '1', 29, None), + ('bytes=25-100', 'bytes 25-29/30', '5', 25, None), + ], + ) + def test_single_range_get(self, range_header, content_range, + content_length, start, end): + route = self.base_url + '/test' + response = self.request(route, headers={'Range': range_header}) + self.assertEqual(response.getheader('content-range'), content_range) + self.assertEqual(response.getheader('content-length'), content_length) + self.check_status_and_reason( + response, HTTPStatus.PARTIAL_CONTENT, data=self.data[start:end]) + + def test_single_range_head(self): + response = self.request( + self.base_url + '/test', method='HEAD', + headers={'Range': 'bytes=2-5'}) + self.check_status_and_reason(response, HTTPStatus.PARTIAL_CONTENT) + self.assertEqual(response.getheader('content-range'), 'bytes 2-5/30') + self.assertEqual(response.getheader('content-length'), '4') + self.assertEqual(response.read(), b'') + + @support.subTests('range_header', [ + 'bytes=4-3', + 'bytes=wrong format', + 'bytes=-', + 'bytes=--', + 'bytes=', + 'bytes=1-2, 4-7', + ]) + def test_invalid_range_get(self, range_header): + route = self.base_url + '/test' + response = self.request(route, headers={'Range': range_header}) + self.check_status_and_reason(response, HTTPStatus.OK, data=self.data) + + def test_range_get_ignored_for_directory_listing(self): + response = self.request( + self.base_url + '/', headers={'Range': 'bytes=0-3'}) + self.check_status_and_reason(response, HTTPStatus.OK) + self.assertIsNone(response.getheader('content-range')) + + @support.subTests('range_header', ['bytes=100-200', 'bytes=-0']) + def test_unsatisfiable_range_get(self, range_header): + route = self.base_url + '/test' + response = self.request(route, headers={'Range': range_header}) + self.assertEqual(response.getheader('content-range'), 'bytes */30') + self.assertEqual(response.getheader('content-length'), '0') + self.check_status_and_reason( + response, HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + + @support.subTests('range_header', ['bytes=0-512', 'bytes=-512']) + def test_single_range_get_empty(self, range_header): + os_helper.create_empty_file(os.path.join(self.tempdir_name, 'empty')) + empty_path = self.base_url + '/empty' + + response = self.request(empty_path, headers={'Range': range_header}) + self.check_status_and_reason(response, HTTPStatus.OK, data=b'') + + @support.subTests('range_header', ['bytes=1-2', 'bytes=-0']) + def test_unsatisfiable_range_get_empty(self, range_header): + os_helper.create_empty_file(os.path.join(self.tempdir_name, 'empty')) + empty_path = self.base_url + '/empty' + + response = self.request(empty_path, headers={'Range': range_header}) + self.assertEqual(response.getheader('content-range'), 'bytes */0') + self.assertEqual(response.getheader('content-length'), '0') + self.check_status_and_reason( + response, HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + def test_head(self): response = self.request( self.base_url + '/test', method='HEAD') From 1aedfade28d73bfce8f58597a828f9ea5eafad88 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sat, 16 Jan 2021 13:02:32 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2026-07-24-13-05-00.gh-issue-86809.vJk93r.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-07-24-13-05-00.gh-issue-86809.vJk93r.rst diff --git a/Misc/NEWS.d/next/Library/2026-07-24-13-05-00.gh-issue-86809.vJk93r.rst b/Misc/NEWS.d/next/Library/2026-07-24-13-05-00.gh-issue-86809.vJk93r.rst new file mode 100644 index 000000000000000..7f1bf4c1acab8b3 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-24-13-05-00.gh-issue-86809.vJk93r.rst @@ -0,0 +1,2 @@ +SimpleHTTPRequestHandler now supports single-part HTTP range requests, as +specified in RFC 9110 section 14.