Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Doc/library/http.server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--

Expand Down
91 changes: 82 additions & 9 deletions Lib/http/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
import mimetypes
import os
import posixpath
import re
import shutil
import socket
import socketserver
Expand Down Expand Up @@ -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):

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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')):
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -959,21 +999,35 @@ 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
-- note however that this the default server uses this
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.
Expand All @@ -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

Expand Down
76 changes: 76 additions & 0 deletions Lib/test/test_httpservers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/')
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SimpleHTTPRequestHandler now supports single-part HTTP range requests, as
specified in RFC 9110 section 14.
Loading