Skip to content

Commit 2064485

Browse files
committed
bpo-42643: Add support for HTTP range requests
1 parent a096314 commit 2064485

4 files changed

Lines changed: 169 additions & 9 deletions

File tree

Doc/library/http.server.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@ instantiation, of which this module provides three different variants:
392392
.. versionchanged:: 3.15
393393
Added *extra_response_headers* parameter.
394394

395+
.. versionchanged:: 3.16
396+
Added support for HTTP single-part range requests for files,
397+
as specified in :rfc:`9110#section-14`.
398+
395399
A lot of the work, such as parsing the request, is done by the base class
396400
:class:`BaseHTTPRequestHandler`. This class implements the :func:`do_GET`
397401
and :func:`do_HEAD` functions.

Doc/whatsnew/3.16.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,13 @@ gzip
272272
which is passed on to the constructor of the :class:`~gzip.GzipFile` class.
273273
(Contributed by Marin Misur in :gh:`91372`.)
274274

275+
http.server
276+
-----------
277+
278+
* :class:`~http.server.SimpleHTTPRequestHandler` now supports HTTP
279+
single-part range requests for files, as specified in :rfc:`9110#section-14`.
280+
(Contributed by David Bord in :gh:`86809`.)
281+
275282
io
276283
--
277284

Lib/http/server.py

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
import mimetypes
7777
import os
7878
import posixpath
79+
import re
7980
import shutil
8081
import socket
8182
import socketserver
@@ -111,6 +112,9 @@
111112
"""
112113

113114
DEFAULT_ERROR_CONTENT_TYPE = "text/html;charset=utf-8"
115+
HTTP_BYTES_RANGE_HEADER = re.compile(
116+
r"bytes=(\d*)-(\d*)$", re.ASCII | re.IGNORECASE)
117+
114118

115119
class HTTPServer(socketserver.TCPServer):
116120

@@ -758,7 +762,7 @@ def do_GET(self):
758762
f = self.send_head()
759763
if f:
760764
try:
761-
self.copyfile(f, self.wfile)
765+
self.copyfile(f, self.wfile, http_range=self._range)
762766
finally:
763767
f.close()
764768

@@ -791,6 +795,7 @@ def send_head(self):
791795
"""
792796
path = self.translate_path(self.path)
793797
f = None
798+
self._range = None
794799
if os.path.isdir(path):
795800
parts = urllib.parse.urlsplit(self.path)
796801
if not parts.path.endswith(('/', '%2f', '%2F')):
@@ -810,6 +815,7 @@ def send_head(self):
810815
break
811816
else:
812817
return self.list_directory(path)
818+
self._range = self.parse_range()
813819
ctype = self.guess_type(path)
814820
# check for trailing "/" which should return 404. See Issue17324
815821
# The test for this was added in test_httpserver.py
@@ -854,10 +860,44 @@ def send_head(self):
854860
self.end_headers()
855861
f.close()
856862
return None
857-
858-
self.send_response(HTTPStatus.OK)
863+
if self._range:
864+
start, end = self._range
865+
if start is None and end == 0:
866+
f.close()
867+
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
868+
self.send_header("Content-Range", f"bytes */{fs.st_size}")
869+
self.send_header("Content-Length", "0")
870+
self.end_headers()
871+
return None
872+
if start is None:
873+
start = max(0, fs.st_size - end)
874+
end = fs.st_size - 1
875+
elif end is None or end >= fs.st_size:
876+
end = fs.st_size - 1
877+
878+
if start == 0 and end >= fs.st_size - 1:
879+
self._range = None
880+
elif start >= fs.st_size:
881+
f.close()
882+
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
883+
self.send_header("Content-Range", f"bytes */{fs.st_size}")
884+
self.send_header("Content-Length", "0")
885+
self.end_headers()
886+
return None
887+
else:
888+
self._range = (start, end)
889+
890+
if self._range:
891+
start, end = self._range
892+
self.send_response(HTTPStatus.PARTIAL_CONTENT)
893+
self.send_header("Content-Range",
894+
f"bytes {start}-{end}/{fs.st_size}")
895+
self.send_header("Content-Length", str(end - start + 1))
896+
else:
897+
self.send_response(HTTPStatus.OK)
898+
self.send_header("Accept-Ranges", "bytes")
899+
self.send_header("Content-Length", str(fs.st_size))
859900
self.send_header("Content-type", ctype)
860-
self.send_header("Content-Length", str(fs[6]))
861901
self.send_header("Last-Modified",
862902
self.date_time_string(fs.st_mtime))
863903
self._send_extra_response_headers()
@@ -959,21 +999,35 @@ def translate_path(self, path):
959999
path += '/'
9601000
return path
9611001

962-
def copyfile(self, source, outputfile):
1002+
def copyfile(self, source, outputfile, *, http_range=None):
9631003
"""Copy all data between two file objects.
9641004
1005+
If *http_range* is provided, copy only that inclusive byte range.
1006+
9651007
The SOURCE argument is a file object open for reading
966-
(or anything with a read() method) and the DESTINATION
967-
argument is a file object open for writing (or
968-
anything with a write() method).
1008+
(or anything with read() and seek() methods) and the DESTINATION
1009+
argument is a file object open for writing (or anything with a
1010+
write() method).
9691011
9701012
The only reason for overriding this would be to change
9711013
the block size or perhaps to replace newlines by CRLF
9721014
-- note however that this the default server uses this
9731015
to copy binary data as well.
9741016
9751017
"""
976-
shutil.copyfileobj(source, outputfile)
1018+
if http_range is None:
1019+
shutil.copyfileobj(source, outputfile)
1020+
return
1021+
1022+
start, end = http_range
1023+
length = end - start + 1
1024+
source.seek(start)
1025+
while length > 0:
1026+
buf = source.read(min(length, shutil.COPY_BUFSIZE))
1027+
if not buf:
1028+
raise EOFError("File shrank after size was checked")
1029+
length -= len(buf)
1030+
outputfile.write(buf)
9771031

9781032
def guess_type(self, path):
9791033
"""Guess the type of a file.
@@ -1000,6 +1054,25 @@ def guess_type(self, path):
10001054
return guess
10011055
return self.default_content_type
10021056

1057+
def parse_range(self):
1058+
"""Parse a single-part Range header into an inclusive byte range."""
1059+
range_header = self.headers.get("Range")
1060+
if range_header is None:
1061+
return None
1062+
1063+
match = HTTP_BYTES_RANGE_HEADER.fullmatch(range_header)
1064+
if match is None:
1065+
return None
1066+
1067+
start = int(match.group(1)) if match.group(1) else None
1068+
end = int(match.group(2)) if match.group(2) else None
1069+
1070+
if start is None and end is None:
1071+
return None
1072+
if start is not None and end is not None and start > end:
1073+
return None
1074+
return start, end
1075+
10031076

10041077
nobody = None
10051078

Lib/test/test_httpservers.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,7 @@ def test_get_dir_redirect_location_domain_injection_bug(self):
786786
def test_get(self):
787787
#constructs the path relative to the root directory of the HTTPServer
788788
response = self.request(self.base_url + '/test')
789+
self.assertEqual(response.getheader('accept-ranges'), 'bytes')
789790
self.check_status_and_reason(response, HTTPStatus.OK, data=self.data)
790791
# check for trailing "/" which should return 404. See Issue17324
791792
response = self.request(self.base_url + '/test/')
@@ -834,6 +835,81 @@ def test_get(self):
834835
finally:
835836
os.chmod(self.tempdir, 0o755)
836837

838+
@support.subTests(
839+
'range_header,content_range,content_length,start,end',
840+
[
841+
('bYtEs=2-5', 'bytes 2-5/30', '4', 2, 6),
842+
('bytes=3-', 'bytes 3-29/30', '27', 3, None),
843+
('bytes=-5', 'bytes 25-29/30', '5', 25, None),
844+
('bytes=29-29', 'bytes 29-29/30', '1', 29, None),
845+
('bytes=25-100', 'bytes 25-29/30', '5', 25, None),
846+
],
847+
)
848+
def test_single_range_get(self, range_header, content_range,
849+
content_length, start, end):
850+
route = self.base_url + '/test'
851+
response = self.request(route, headers={'Range': range_header})
852+
self.assertEqual(response.getheader('content-range'), content_range)
853+
self.assertEqual(response.getheader('content-length'), content_length)
854+
self.check_status_and_reason(
855+
response, HTTPStatus.PARTIAL_CONTENT, data=self.data[start:end])
856+
857+
def test_single_range_head(self):
858+
response = self.request(
859+
self.base_url + '/test', method='HEAD',
860+
headers={'Range': 'bytes=2-5'})
861+
self.check_status_and_reason(response, HTTPStatus.PARTIAL_CONTENT)
862+
self.assertEqual(response.getheader('content-range'), 'bytes 2-5/30')
863+
self.assertEqual(response.getheader('content-length'), '4')
864+
self.assertEqual(response.read(), b'')
865+
866+
@support.subTests('range_header', [
867+
'bytes=4-3',
868+
'bytes=wrong format',
869+
'bytes=-',
870+
'bytes=--',
871+
'bytes=',
872+
'bytes=1-2, 4-7',
873+
])
874+
def test_invalid_range_get(self, range_header):
875+
route = self.base_url + '/test'
876+
response = self.request(route, headers={'Range': range_header})
877+
self.check_status_and_reason(response, HTTPStatus.OK, data=self.data)
878+
879+
def test_range_get_ignored_for_directory_listing(self):
880+
response = self.request(
881+
self.base_url + '/', headers={'Range': 'bytes=0-3'})
882+
self.check_status_and_reason(response, HTTPStatus.OK)
883+
self.assertIsNone(response.getheader('content-range'))
884+
885+
@support.subTests('range_header', ['bytes=100-200', 'bytes=-0'])
886+
def test_unsatisfiable_range_get(self, range_header):
887+
route = self.base_url + '/test'
888+
response = self.request(route, headers={'Range': range_header})
889+
self.assertEqual(response.getheader('content-range'), 'bytes */30')
890+
self.assertEqual(response.getheader('content-length'), '0')
891+
self.check_status_and_reason(
892+
response, HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
893+
894+
@support.subTests('range_header', ['bytes=0-512', 'bytes=-512'])
895+
def test_single_range_get_empty(self, range_header):
896+
os_helper.create_empty_file(os.path.join(self.tempdir_name, 'empty'))
897+
empty_path = self.base_url + '/empty'
898+
899+
response = self.request(empty_path, headers={'Range': range_header})
900+
self.check_status_and_reason(response, HTTPStatus.OK, data=b'')
901+
902+
@support.subTests('range_header', ['bytes=1-2', 'bytes=-0'])
903+
def test_unsatisfiable_range_get_empty(self, range_header):
904+
os_helper.create_empty_file(os.path.join(self.tempdir_name, 'empty'))
905+
empty_path = self.base_url + '/empty'
906+
907+
response = self.request(empty_path, headers={'Range': range_header})
908+
self.assertEqual(response.getheader('content-range'), 'bytes */0')
909+
self.assertEqual(response.getheader('content-length'), '0')
910+
self.check_status_and_reason(
911+
response, HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
912+
837913
def test_head(self):
838914
response = self.request(
839915
self.base_url + '/test', method='HEAD')

0 commit comments

Comments
 (0)