diff --git a/sendfile/__init__.py b/sendfile/__init__.py index 752acdd..551f259 100644 --- a/sendfile/__init__.py +++ b/sendfile/__init__.py @@ -32,7 +32,8 @@ def _get_sendfile(): -def sendfile(request, filename, attachment=False, attachment_filename=None, mimetype=None, encoding=None): +def sendfile(request, filename, attachment=False, attachment_filename=None, mimetype=None, encoding=None, + accept_ranges=False): ''' create a response to send file using backend configured in SENDFILE_BACKEND @@ -42,6 +43,10 @@ def sendfile(request, filename, attachment=False, attachment_filename=None, mime If no mimetype or encoding are specified, then they will be guessed via the filename (using the standard python mimetypes module) + + If accept_ranges is True, the Accept-Ranges HTTP header is added to the + response. It also enables the range request support in the + sendfile.backends.simple backend. ''' _sendfile = _get_sendfile() @@ -55,13 +60,16 @@ def sendfile(request, filename, attachment=False, attachment_filename=None, mime mimetype = guessed_mimetype else: mimetype = 'application/octet-stream' - - response = _sendfile(request, filename, mimetype=mimetype) + + response = _sendfile(request, filename, mimetype=mimetype, accept_ranges=accept_ranges) if attachment: attachment_filename = attachment_filename or os.path.basename(filename) response['Content-Disposition'] = 'attachment; filename="%s"' % attachment_filename - response['Content-length'] = os.path.getsize(filename) + if accept_ranges: + response['Accept-Ranges'] = 'bytes' + if 'Content-Length' not in response: + response['Content-Length'] = os.path.getsize(filename) response['Content-Type'] = mimetype if not encoding: encoding = guessed_encoding diff --git a/sendfile/backends/simple.py b/sendfile/backends/simple.py index 2f103ea..78ab821 100644 --- a/sendfile/backends/simple.py +++ b/sendfile/backends/simple.py @@ -7,6 +7,13 @@ from django.http import HttpResponse, HttpResponseNotModified from django.utils.http import http_date +try: + # New in Django 1.5 + from django.http import StreamingHttpResponse +except ImportError: + StreamingHttpResponse = None + + def sendfile(request, filename, **kwargs): # Respect the If-Modified-Since header. statobj = os.stat(filename) @@ -14,13 +21,62 @@ def sendfile(request, filename, **kwargs): if not was_modified_since(request.META.get('HTTP_IF_MODIFIED_SINCE'), statobj[stat.ST_MTIME], statobj[stat.ST_SIZE]): return HttpResponseNotModified() - - - response = HttpResponse(File(file(filename, 'rb'))) + if StreamingHttpResponse is None: + # Django < 1.5 + response = HttpResponse(File(open(filename, 'rb'))) + response["Last-Modified"] = http_date(statobj[stat.ST_MTIME]) + return response + + fileiter = _FileStreamer(filename) + + response = StreamingHttpResponse(fileiter) response["Last-Modified"] = http_date(statobj[stat.ST_MTIME]) + + # Parse range request, if any + start = None + stop = None + + range_request = request.META.get('HTTP_RANGE', '').strip() + m = re.match(r'^bytes=(?P\d+)?-(?P\d+)?$', range_request) + if m and kwargs.get('accept_ranges', False): + start, stop = m.groups() + try: + if start is not None: + start = int(start) + if stop is not None: + stop = int(stop) + if start is not None and stop < start: + raise ValueError() + if start is None and stop is None: + raise ValueError() + + if start is None: + start = 0 + if stop is None: + stop = fileiter.file_size - 1 + except ValueError: + # invalid header --- MUST be ignored + start = None + stop = None + + # Prepare serving ranges + if start is not None: + try: + fileiter.set_range(start, stop) + response.status_code = 206 + response['Content-Range'] = 'bytes %d-%d/%d' % (start, stop, + fileiter.file_size) + response['Content-Length'] = stop + 1 - start + except ValueError: + # Out of bounds + response = HttpResponse(status=416) + response['Content-Range'] = 'bytes */%d' % (fileiter.file_size,) + return response + return response - + + def was_modified_since(header=None, mtime=0, size=0): """ Was something modified since the user last downloaded it? @@ -53,3 +109,56 @@ def was_modified_since(header=None, mtime=0, size=0): return True return False + +class _FileStreamer(object): + """ + Streaming file iterator for Django's StreamingHttpResponse. + Also supports streaming only a part of the file. + """ + + BLOCK_SIZE = 131072 + + def __init__(self, filename): + self.fp = open(filename, 'rb') + self.file_size = os.path.getsize(filename) + self.start = None + self.stop = None # inclusive! + self.pos = 0 + + def set_range(self, start, stop): + if not ((0 <= start < self.file_size) and (0 <= stop < self.file_size)): + raise ValueError("Start or stop out of bounds") + + self.fp.seek(start) + self.pos = start + self.start = start + self.stop = stop + + def close(self): + if self.fp is not None: + self.fp.close() + self.stop = -1 + self.fp = None + + def __iter__(self): + return self + + def next(self): + if self.stop is not None: + block_size = min(_FileStreamer.BLOCK_SIZE, self.stop + 1 - self.pos) + if block_size <= 0: + self.close() + raise StopIteration() + else: + block_size = _FileStreamer.BLOCK_SIZE + + block = self.fp.read(block_size) + self.pos += len(block) + + if not block: + self.close() + raise StopIteration() + + return block + + __next__ = next diff --git a/sendfile/tests.py b/sendfile/tests.py index f742828..c698540 100644 --- a/sendfile/tests.py +++ b/sendfile/tests.py @@ -3,9 +3,12 @@ from django.conf import settings from django.test import TestCase from django.http import HttpResponse, Http404, HttpRequest +import django.http + import os.path from tempfile import mkdtemp import shutil +import random from sendfile import sendfile as real_sendfile, _get_sendfile @@ -26,10 +29,13 @@ def tearDown(self): if os.path.exists(self.TEMP_FILE_ROOT): shutil.rmtree(self.TEMP_FILE_ROOT) - def ensure_file(self, filename): + def ensure_file(self, filename, length=0): path = os.path.join(self.TEMP_FILE_ROOT, filename) if not os.path.exists(path): - open(path, 'w').close() + f = open(path, 'w') + if length > 0: + f.write("".join([chr(random.randint(0, 255)) for _ in range(length)])) + f.close() return path @@ -77,6 +83,90 @@ def test_attachment_filename(self): self.assertEqual('attachment; filename="tests.txt"', response['Content-Disposition']) +class TestSimpleBackend(TempFileTestCase): + + def setUp(self): + super(TestSimpleBackend, self).setUp() + settings.SENDFILE_BACKEND = 'sendfile.backends.simple' + _get_sendfile.clear() + + self.filepath = self.ensure_file('readme.txt', length=90) + f = open(self.filepath, 'rb') + self.filecontent = f.read() + f.close() + + def _verify_response_content(self, response, start, stop): + result = [] + for block in response.streaming_content: + result.append(block) + result = "".join(result) + + self.assertEqual(len(result), len(self.filecontent[start:stop])) + self.assertEqual(result, self.filecontent[start:stop]) + + if hasattr(django.http, 'StreamingHttpResponse'): + # Django >= 1.5 + + def test_range_request_header(self): + request = HttpRequest() + response = real_sendfile(request, self.filepath, accept_ranges=True) + self.assertEqual(response['Accept-ranges'], 'bytes') + self.assertEqual(response['Content-length'], '90') + self._verify_response_content(response, 0, 90) + + # Request with both bounds + request = HttpRequest() + request.META['HTTP_RANGE'] = 'bytes=5-7' + response = real_sendfile(request, self.filepath, accept_ranges=True) + self.assertEqual(response.status_code, 206) + self.assertEqual(response['Content-length'], '3') + self.assertEqual(response['Content-range'], 'bytes 5-7/90') + self._verify_response_content(response, 5, 7+1) + + # Request with start bound + request = HttpRequest() + request.META['HTTP_RANGE'] = 'bytes=1-' + response = real_sendfile(request, self.filepath, accept_ranges=True) + self.assertEqual(response.status_code, 206) + self.assertEqual(response['Content-length'], '89') + self.assertEqual(response['Content-range'], 'bytes 1-89/90') + self._verify_response_content(response, 1, 90) + + # Request with end bound + request = HttpRequest() + request.META['HTTP_RANGE'] = 'bytes=-1' + response = real_sendfile(request, self.filepath, accept_ranges=True) + self.assertEqual(response.status_code, 206) + self.assertEqual(response['Content-length'], '2') + self.assertEqual(response['Content-range'], 'bytes 0-1/90') + self._verify_response_content(response, 0, 1+1) + + # Out of bounds request + request = HttpRequest() + request.META['HTTP_RANGE'] = 'bytes=0-90' + response = real_sendfile(request, self.filepath, accept_ranges=True) + self.assertEqual(response['Content-range'], 'bytes */90') + self.assertEqual(response.status_code, 416) + + # Malformed headers, MUST be ignored according to spec + for hdr in ['bytes=-', 'bytes=foo', 'bytes=3-2']: + request = HttpRequest() + request.META['HTTP_RANGE'] = hdr + response = real_sendfile(request, self.filepath, + accept_ranges=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-length'], '90') + self._verify_response_content(response, 0, 90) + + # Without accept-ranges, it should be disabled + request = HttpRequest() + request.META['HTTP_RANGE'] = 'bytes=5-7' + response = real_sendfile(request, self.filepath) + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-length'], '90') + self._verify_response_content(response, 0, 90) + + class TestXSendfileBackend(TempFileTestCase): def setUp(self):