Skip to content

Commit 2ce75c5

Browse files
committed
Add support from the HTTP range header
1 parent 401be8a commit 2ce75c5

File tree

2 files changed

+139
-3
lines changed

2 files changed

+139
-3
lines changed

django/views/static.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,32 @@ def serve(request, path, document_root=None, show_indexes=False):
6161
return HttpResponseNotModified()
6262
content_type, encoding = mimetypes.guess_type(fullpath)
6363
content_type = content_type or 'application/octet-stream'
64-
response = StreamingHttpResponse(open(fullpath, 'rb'),
64+
ranged_file = RangedFileReader(open(fullpath, 'rb'))
65+
response = StreamingHttpResponse(ranged_file,
6566
content_type=content_type)
6667
response["Last-Modified"] = http_date(statobj.st_mtime)
6768
if stat.S_ISREG(statobj.st_mode):
68-
response["Content-Length"] = statobj.st_size
69+
size = statobj.st_size
70+
response["Content-Length"] = size
71+
response["Accept-Ranges"] = "bytes"
72+
# Respect the Range header.
73+
if "HTTP_RANGE" in request.META:
74+
try:
75+
ranges = parse_range_header(request.META['HTTP_RANGE'], size)
76+
except ValueError:
77+
ranges = None
78+
# only handle syntactically valid headers, that are simple (no
79+
# multipart byteranges)
80+
if ranges is not None and len(ranges) == 1:
81+
start, stop = ranges[0]
82+
if stop > size:
83+
# requested range not satisfiable
84+
return HttpResponse(status=416)
85+
ranged_file.start = start
86+
ranged_file.stop = stop
87+
response["Content-Range"] = "bytes %d-%d/%d" % (start, stop - 1, size)
88+
response["Content-Length"] = stop - start
89+
response.status_code = 206
6990
if encoding:
7091
response["Content-Encoding"] = encoding
7192
return response
@@ -144,3 +165,73 @@ def was_modified_since(header=None, mtime=0, size=0):
144165
except (AttributeError, ValueError, OverflowError):
145166
return True
146167
return False
168+
169+
170+
def parse_range_header(header, resource_size):
171+
"""
172+
Parses a range header into a list of two-tuples (start, stop) where `start`
173+
is the starting byte of the range (inclusive) and `stop` is the ending byte
174+
position of the range (exclusive).
175+
176+
Returns None if the value of the header is not syntatically valid.
177+
"""
178+
if not header or '=' not in header:
179+
return None
180+
181+
ranges = []
182+
units, range_ = header.split('=', 1)
183+
units = units.strip().lower()
184+
185+
if units != "bytes":
186+
return None
187+
188+
for val in range_.split(","):
189+
val = val.strip()
190+
if '-' not in val:
191+
return None
192+
193+
if val.startswith("-"):
194+
# suffix-byte-range-spec: this form specifies the last N bytes of an
195+
# entity-body
196+
start = resource_size + int(val)
197+
if start < 0:
198+
start = 0
199+
stop = resource_size
200+
else:
201+
# byte-range-spec: first-byte-pos "-" [last-byte-pos]
202+
start, stop = val.split("-", 1)
203+
start = int(start)
204+
# the +1 is here since we want the stopping point to be exclusive, whereas in
205+
# the HTTP spec, the last-byte-pos is inclusive
206+
stop = int(stop)+1 if stop else resource_size
207+
if start >= stop:
208+
return None
209+
210+
ranges.append((start, stop))
211+
212+
return ranges
213+
214+
215+
class RangedFileReader:
216+
"""
217+
Wraps a file like object with an iterator that runs over part (or all) of
218+
the file defined by start and stop. Blocks of block_size will be returned
219+
from the starting position, up to, but not including the stop point.
220+
"""
221+
block_size = 8192
222+
def __init__(self, file_like, start=0, stop=float("inf"), block_size=None):
223+
self.f = file_like
224+
self.block_size = block_size or RangedFileReader.block_size
225+
self.start = start
226+
self.stop = stop
227+
228+
def __iter__(self):
229+
self.f.seek(self.start)
230+
position = self.start
231+
while position < self.stop:
232+
data = self.f.read(min(self.block_size, self.stop - position))
233+
if not data:
234+
break
235+
236+
yield data
237+
position += self.block_size

tests/view_tests/tests/test_static.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.http import HttpResponseNotModified
99
from django.test import SimpleTestCase, override_settings
1010
from django.utils.http import http_date
11-
from django.views.static import was_modified_since
11+
from django.views.static import was_modified_since, RangedFileReader
1212

1313
from .. import urls
1414
from ..urls import media_dir
@@ -95,6 +95,51 @@ def test_404(self):
9595
response = self.client.get('/%s/non_existing_resource' % self.prefix)
9696
self.assertEqual(404, response.status_code)
9797

98+
def test_accept_ranges(self):
99+
response = self.client.get('/%s/%s' % (self.prefix, "file.txt"))
100+
self.assertEqual(response['Accept-Ranges'], "bytes")
101+
102+
def test_syntactically_invalid_ranges(self):
103+
"""
104+
Test that a syntactically invalid byte range header is ignored and the
105+
response gives back the whole resource as per RFC 2616, section 14.35.1
106+
"""
107+
content = open(path.join(media_dir, "file.txt")).read()
108+
invalid = ["megabytes=1-2", "bytes=", "bytes=3-2", "bytes=--5", "units", "bytes=-,"]
109+
for range_ in invalid:
110+
response = self.client.get('/%s/%s' % (self.prefix, "file.txt"), HTTP_RANGE=range_)
111+
self.assertEqual(content, b''.join(response))
112+
113+
def test_unsatisfiable_range(self):
114+
"""Test that an unsatisfiable range results in a 416 HTTP status code"""
115+
content = open(path.join(media_dir, "file.txt")).read()
116+
# since byte ranges are *inclusive*, 0 to len(content) would be unsatisfiable
117+
response = self.client.get('/%s/%s' % (self.prefix, "file.txt"), HTTP_RANGE="bytes=0-%d" % len(content))
118+
self.assertEqual(response.status_code, 416)
119+
120+
def test_ranges(self):
121+
# set the block size to something small so we do multiple iterations in
122+
# the RangedFileReader class
123+
original_block_size = RangedFileReader.block_size
124+
RangedFileReader.block_size = 3
125+
126+
content = open(path.join(media_dir, "file.txt")).read()
127+
# specify the range header, the expected response content, and the
128+
# values of the content-range header byte positions
129+
ranges = {
130+
"bytes=0-10": (content[0:11], (0, 10)),
131+
"bytes=9-9": (content[9:10], (9, 9)),
132+
"bytes=-5": (content[len(content)-5:], (len(content)-5, len(content)-1)),
133+
"bytes=3-": (content[3:], (3, len(content)-1)),
134+
"bytes=-%d" % (len(content) + 1): (content, (0, len(content)-1)),
135+
}
136+
for range_, (expected_result, byte_positions) in ranges.items():
137+
response = self.client.get('/%s/%s' % (self.prefix, "file.txt"), HTTP_RANGE=range_)
138+
self.assertEqual(expected_result, b''.join(response))
139+
self.assertEqual(int(response['Content-Length']), len(expected_result))
140+
self.assertEqual(response['Content-Range'], "bytes %d-%d/%d" % (byte_positions + (len(content),)))
141+
142+
RangedFileReader.block_size = original_block_size
98143

99144
class StaticHelperTest(StaticTests):
100145
"""

0 commit comments

Comments
 (0)