Permalink
Browse files

Add support from the HTTP range header

1 parent 401be8a commit 2ce75c5c4bee2a858c0214d136bfcd351fcde11d @satchamo committed Apr 16, 2014
Showing with 139 additions and 3 deletions.
  1. +93 −2 django/views/static.py
  2. +46 −1 tests/view_tests/tests/test_static.py
@@ -61,11 +61,32 @@ def serve(request, path, document_root=None, show_indexes=False):
return HttpResponseNotModified()
content_type, encoding = mimetypes.guess_type(fullpath)
content_type = content_type or 'application/octet-stream'
- response = StreamingHttpResponse(open(fullpath, 'rb'),
+ ranged_file = RangedFileReader(open(fullpath, 'rb'))
+ response = StreamingHttpResponse(ranged_file,
content_type=content_type)
response["Last-Modified"] = http_date(statobj.st_mtime)
if stat.S_ISREG(statobj.st_mode):
- response["Content-Length"] = statobj.st_size
+ size = statobj.st_size
+ response["Content-Length"] = size
+ response["Accept-Ranges"] = "bytes"
+ # Respect the Range header.
+ if "HTTP_RANGE" in request.META:
+ try:
+ ranges = parse_range_header(request.META['HTTP_RANGE'], size)
+ except ValueError:
+ ranges = None
+ # only handle syntactically valid headers, that are simple (no
+ # multipart byteranges)
+ if ranges is not None and len(ranges) == 1:
+ start, stop = ranges[0]
+ if stop > size:
+ # requested range not satisfiable
+ return HttpResponse(status=416)
+ ranged_file.start = start
+ ranged_file.stop = stop
+ response["Content-Range"] = "bytes %d-%d/%d" % (start, stop - 1, size)
+ response["Content-Length"] = stop - start
+ response.status_code = 206
if encoding:
response["Content-Encoding"] = encoding
return response
@@ -144,3 +165,73 @@ def was_modified_since(header=None, mtime=0, size=0):
except (AttributeError, ValueError, OverflowError):
return True
return False
+
+
+def parse_range_header(header, resource_size):
+ """
+ Parses a range header into a list of two-tuples (start, stop) where `start`
+ is the starting byte of the range (inclusive) and `stop` is the ending byte
+ position of the range (exclusive).
+
+ Returns None if the value of the header is not syntatically valid.
+ """
+ if not header or '=' not in header:
+ return None
+
+ ranges = []
+ units, range_ = header.split('=', 1)
+ units = units.strip().lower()
+
+ if units != "bytes":
+ return None
+
+ for val in range_.split(","):
+ val = val.strip()
+ if '-' not in val:
+ return None
+
+ if val.startswith("-"):
+ # suffix-byte-range-spec: this form specifies the last N bytes of an
+ # entity-body
+ start = resource_size + int(val)
+ if start < 0:
+ start = 0
+ stop = resource_size
+ else:
+ # byte-range-spec: first-byte-pos "-" [last-byte-pos]
+ start, stop = val.split("-", 1)
+ start = int(start)
+ # the +1 is here since we want the stopping point to be exclusive, whereas in
+ # the HTTP spec, the last-byte-pos is inclusive
+ stop = int(stop)+1 if stop else resource_size
+ if start >= stop:
+ return None
+
+ ranges.append((start, stop))
+
+ return ranges
+
+
+class RangedFileReader:
+ """
+ Wraps a file like object with an iterator that runs over part (or all) of
+ the file defined by start and stop. Blocks of block_size will be returned
+ from the starting position, up to, but not including the stop point.
+ """
+ block_size = 8192
+ def __init__(self, file_like, start=0, stop=float("inf"), block_size=None):
+ self.f = file_like
+ self.block_size = block_size or RangedFileReader.block_size
+ self.start = start
+ self.stop = stop
+
+ def __iter__(self):
+ self.f.seek(self.start)
+ position = self.start
+ while position < self.stop:
+ data = self.f.read(min(self.block_size, self.stop - position))
+ if not data:
+ break
+
+ yield data
+ position += self.block_size
@@ -8,7 +8,7 @@
from django.http import HttpResponseNotModified
from django.test import SimpleTestCase, override_settings
from django.utils.http import http_date
-from django.views.static import was_modified_since
+from django.views.static import was_modified_since, RangedFileReader
from .. import urls
from ..urls import media_dir
@@ -95,6 +95,51 @@ def test_404(self):
response = self.client.get('/%s/non_existing_resource' % self.prefix)
self.assertEqual(404, response.status_code)
+ def test_accept_ranges(self):
+ response = self.client.get('/%s/%s' % (self.prefix, "file.txt"))
+ self.assertEqual(response['Accept-Ranges'], "bytes")
+
+ def test_syntactically_invalid_ranges(self):
+ """
+ Test that a syntactically invalid byte range header is ignored and the
+ response gives back the whole resource as per RFC 2616, section 14.35.1
+ """
+ content = open(path.join(media_dir, "file.txt")).read()
+ invalid = ["megabytes=1-2", "bytes=", "bytes=3-2", "bytes=--5", "units", "bytes=-,"]
+ for range_ in invalid:
+ response = self.client.get('/%s/%s' % (self.prefix, "file.txt"), HTTP_RANGE=range_)
+ self.assertEqual(content, b''.join(response))
+
+ def test_unsatisfiable_range(self):
+ """Test that an unsatisfiable range results in a 416 HTTP status code"""
+ content = open(path.join(media_dir, "file.txt")).read()
+ # since byte ranges are *inclusive*, 0 to len(content) would be unsatisfiable
+ response = self.client.get('/%s/%s' % (self.prefix, "file.txt"), HTTP_RANGE="bytes=0-%d" % len(content))
+ self.assertEqual(response.status_code, 416)
+
+ def test_ranges(self):
+ # set the block size to something small so we do multiple iterations in
+ # the RangedFileReader class
+ original_block_size = RangedFileReader.block_size
+ RangedFileReader.block_size = 3
+
+ content = open(path.join(media_dir, "file.txt")).read()
+ # specify the range header, the expected response content, and the
+ # values of the content-range header byte positions
+ ranges = {
+ "bytes=0-10": (content[0:11], (0, 10)),
+ "bytes=9-9": (content[9:10], (9, 9)),
+ "bytes=-5": (content[len(content)-5:], (len(content)-5, len(content)-1)),
+ "bytes=3-": (content[3:], (3, len(content)-1)),
+ "bytes=-%d" % (len(content) + 1): (content, (0, len(content)-1)),
+ }
+ for range_, (expected_result, byte_positions) in ranges.items():
+ response = self.client.get('/%s/%s' % (self.prefix, "file.txt"), HTTP_RANGE=range_)
+ self.assertEqual(expected_result, b''.join(response))
+ self.assertEqual(int(response['Content-Length']), len(expected_result))
+ self.assertEqual(response['Content-Range'], "bytes %d-%d/%d" % (byte_positions + (len(content),)))
+
+ RangedFileReader.block_size = original_block_size
class StaticHelperTest(StaticTests):
"""

0 comments on commit 2ce75c5

Please sign in to comment.