Browse Source

Fixed #24072 -- Added FileResponse for streaming binary files.

Collin Anderson 10 years ago
parent
commit
3d2cae0896

+ 2 - 0
django/core/handlers/wsgi.py

@@ -197,6 +197,8 @@ class WSGIHandler(base.BaseHandler):
         for c in response.cookies.values():
             response_headers.append((str('Set-Cookie'), str(c.output(header=''))))
         start_response(force_str(status), response_headers)
+        if getattr(response, 'file_to_stream', None) is not None and environ.get('wsgi.file_wrapper'):
+            response = environ['wsgi.file_wrapper'](response.file_to_stream)
         return response
 
 

+ 5 - 3
django/http/__init__.py

@@ -1,11 +1,13 @@
 from django.http.cookie import SimpleCookie, parse_cookie
 from django.http.request import (HttpRequest, QueryDict,
     RawPostDataException, UnreadablePostError, build_request_repr)
-from django.http.response import (HttpResponse, StreamingHttpResponse,
+from django.http.response import (
+    HttpResponse, StreamingHttpResponse, FileResponse,
     HttpResponseRedirect, HttpResponsePermanentRedirect,
     HttpResponseNotModified, HttpResponseBadRequest, HttpResponseForbidden,
     HttpResponseNotFound, HttpResponseNotAllowed, HttpResponseGone,
-    HttpResponseServerError, Http404, BadHeaderError, JsonResponse)
+    HttpResponseServerError, Http404, BadHeaderError, JsonResponse,
+)
 from django.http.utils import fix_location_header, conditional_content_removal
 
 __all__ = [
@@ -16,5 +18,5 @@ __all__ = [
     'HttpResponseBadRequest', 'HttpResponseForbidden', 'HttpResponseNotFound',
     'HttpResponseNotAllowed', 'HttpResponseGone', 'HttpResponseServerError',
     'Http404', 'BadHeaderError', 'fix_location_header', 'JsonResponse',
-    'conditional_content_removal',
+    'FileResponse', 'conditional_content_removal',
 ]

+ 19 - 0
django/http/response.py

@@ -417,6 +417,9 @@ class StreamingHttpResponse(HttpResponseBase):
 
     @streaming_content.setter
     def streaming_content(self, value):
+        self._set_streaming_content(value)
+
+    def _set_streaming_content(self, value):
         # Ensure we can never iterate on "value" more than once.
         self._iterator = iter(value)
         if hasattr(value, 'close'):
@@ -429,6 +432,22 @@ class StreamingHttpResponse(HttpResponseBase):
         return b''.join(self.streaming_content)
 
 
+class FileResponse(StreamingHttpResponse):
+    """
+    A streaming HTTP response class optimized for files.
+    """
+    block_size = 4096
+
+    def _set_streaming_content(self, value):
+        if hasattr(value, 'read'):
+            self.file_to_stream = value
+            filelike = value
+            value = iter(lambda: filelike.read(self.block_size), b'')
+        else:
+            self.file_to_stream = None
+        super(FileResponse, self)._set_streaming_content(value)
+
+
 class HttpResponseRedirectBase(HttpResponse):
     allowed_schemes = ['http', 'https', 'ftp']
 

+ 2 - 3
django/views/static.py

@@ -11,7 +11,7 @@ import posixpath
 import re
 
 from django.http import (Http404, HttpResponse, HttpResponseRedirect,
-    HttpResponseNotModified, StreamingHttpResponse)
+    HttpResponseNotModified, FileResponse)
 from django.template import loader, Template, Context, TemplateDoesNotExist
 from django.utils.http import http_date, parse_http_date
 from django.utils.six.moves.urllib.parse import unquote
@@ -63,8 +63,7 @@ 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'),
-                                     content_type=content_type)
+    response = FileResponse(open(fullpath, 'rb'), 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

+ 18 - 0
docs/ref/request-response.txt

@@ -998,3 +998,21 @@ Attributes
 .. attribute:: StreamingHttpResponse.streaming
 
     This is always ``True``.
+
+FileResponse objects
+====================
+
+.. versionadded:: 1.8
+
+.. class:: FileResponse
+
+:class:`FileResponse` is a subclass of :class:`StreamingHttpResponse` optimized
+for binary files. It uses `wsgi.file_wrapper`_ if provided by the wsgi server,
+otherwise it streams the file out in small chunks.
+
+.. _wsgi.file_wrapper: https://www.python.org/dev/peps/pep-3333/#optional-platform-specific-file-handling
+
+``FileResponse`` expects a file open in binary mode like so::
+
+    >>> from django.http import FileResponse
+    >>> response = FileResponse(open('myfile.png', 'rb'))

+ 2 - 0
docs/releases/1.8.txt

@@ -559,6 +559,8 @@ Requests and Responses
   <django.http.HttpResponse.setdefault>` method allows setting a header unless
   it has already been set.
 
+* You can use the new :class:`~django.http.FileResponse` to stream files.
+
 * The :func:`~django.views.decorators.http.condition` decorator for
   conditional view processing now supports the ``If-unmodified-since`` header.
 

+ 16 - 2
tests/middleware/tests.py

@@ -10,8 +10,8 @@ from unittest import skipIf
 from django.conf import settings
 from django.core import mail
 from django.http import (
-    HttpRequest, HttpResponse, StreamingHttpResponse, HttpResponsePermanentRedirect,
-    HttpResponseRedirect,
+    HttpRequest, HttpResponse, StreamingHttpResponse, FileResponse,
+    HttpResponseRedirect, HttpResponsePermanentRedirect,
 )
 from django.middleware.clickjacking import XFrameOptionsMiddleware
 from django.middleware.common import CommonMiddleware, BrokenLinkEmailsMiddleware
@@ -624,6 +624,20 @@ class GZipMiddlewareTest(TestCase):
         self.assertEqual(r.get('Content-Encoding'), 'gzip')
         self.assertFalse(r.has_header('Content-Length'))
 
+    def test_compress_file_response(self):
+        """
+        Tests that compression is performed on FileResponse.
+        """
+        open_file = lambda: open(__file__, 'rb')
+        with open_file() as file1:
+            file_resp = FileResponse(file1)
+            file_resp['Content-Type'] = 'text/html; charset=UTF-8'
+            r = GZipMiddleware().process_response(self.req, file_resp)
+            with open_file() as file2:
+                self.assertEqual(self.decompress(b''.join(r)), file2.read())
+            self.assertEqual(r.get('Content-Encoding'), 'gzip')
+            self.assertIsNot(r.file_to_stream, file1)
+
     def test_compress_non_200_response(self):
         """
         Tests that compression is performed on responses with a status other than 200.

+ 22 - 0
tests/wsgi/tests.py

@@ -51,6 +51,28 @@ class WSGITest(TestCase):
             bytes(response),
             b"Content-Type: text/html; charset=utf-8\r\n\r\nHello World!")
 
+    def test_file_wrapper(self):
+        """
+        Verify that FileResponse uses wsgi.file_wrapper.
+        """
+        class FileWrapper(object):
+            def __init__(self, filelike, blksize=8192):
+                filelike.close()
+        application = get_wsgi_application()
+        environ = RequestFactory()._base_environ(
+            PATH_INFO='/file/',
+            REQUEST_METHOD='GET',
+            **{'wsgi.file_wrapper': FileWrapper}
+        )
+        response_data = {}
+
+        def start_response(status, headers):
+            response_data['status'] = status
+            response_data['headers'] = headers
+        response = application(environ, start_response)
+        self.assertEqual(response_data['status'], '200 OK')
+        self.assertIsInstance(response, FileWrapper)
+
 
 class GetInternalWSGIApplicationTest(unittest.TestCase):
     @override_settings(WSGI_APPLICATION="wsgi.wsgi.application")

+ 2 - 1
tests/wsgi/urls.py

@@ -1,5 +1,5 @@
 from django.conf.urls import url
-from django.http import HttpResponse
+from django.http import HttpResponse, FileResponse
 
 
 def helloworld(request):
@@ -7,4 +7,5 @@ def helloworld(request):
 
 urlpatterns = [
     url("^$", helloworld),
+    url(r'^file/$', lambda x: FileResponse(open(__file__, 'rb'))),
 ]