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

This commit is contained in:
Collin Anderson 2015-01-03 12:06:24 -05:00 committed by Tim Graham
parent 05f702b94c
commit 3d2cae0896
9 changed files with 88 additions and 9 deletions

View File

@ -197,6 +197,8 @@ class WSGIHandler(base.BaseHandler):
for c in response.cookies.values(): for c in response.cookies.values():
response_headers.append((str('Set-Cookie'), str(c.output(header='')))) response_headers.append((str('Set-Cookie'), str(c.output(header=''))))
start_response(force_str(status), response_headers) 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 return response

View File

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

View File

@ -417,6 +417,9 @@ class StreamingHttpResponse(HttpResponseBase):
@streaming_content.setter @streaming_content.setter
def streaming_content(self, value): 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. # Ensure we can never iterate on "value" more than once.
self._iterator = iter(value) self._iterator = iter(value)
if hasattr(value, 'close'): if hasattr(value, 'close'):
@ -429,6 +432,22 @@ class StreamingHttpResponse(HttpResponseBase):
return b''.join(self.streaming_content) 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): class HttpResponseRedirectBase(HttpResponse):
allowed_schemes = ['http', 'https', 'ftp'] allowed_schemes = ['http', 'https', 'ftp']

View File

@ -11,7 +11,7 @@ import posixpath
import re import re
from django.http import (Http404, HttpResponse, HttpResponseRedirect, from django.http import (Http404, HttpResponse, HttpResponseRedirect,
HttpResponseNotModified, StreamingHttpResponse) HttpResponseNotModified, FileResponse)
from django.template import loader, Template, Context, TemplateDoesNotExist from django.template import loader, Template, Context, TemplateDoesNotExist
from django.utils.http import http_date, parse_http_date from django.utils.http import http_date, parse_http_date
from django.utils.six.moves.urllib.parse import unquote 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() return HttpResponseNotModified()
content_type, encoding = mimetypes.guess_type(fullpath) content_type, encoding = mimetypes.guess_type(fullpath)
content_type = content_type or 'application/octet-stream' content_type = content_type or 'application/octet-stream'
response = StreamingHttpResponse(open(fullpath, 'rb'), response = FileResponse(open(fullpath, 'rb'), content_type=content_type)
content_type=content_type)
response["Last-Modified"] = http_date(statobj.st_mtime) response["Last-Modified"] = http_date(statobj.st_mtime)
if stat.S_ISREG(statobj.st_mode): if stat.S_ISREG(statobj.st_mode):
response["Content-Length"] = statobj.st_size response["Content-Length"] = statobj.st_size

View File

@ -998,3 +998,21 @@ Attributes
.. attribute:: StreamingHttpResponse.streaming .. attribute:: StreamingHttpResponse.streaming
This is always ``True``. 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'))

View File

@ -559,6 +559,8 @@ Requests and Responses
<django.http.HttpResponse.setdefault>` method allows setting a header unless <django.http.HttpResponse.setdefault>` method allows setting a header unless
it has already been set. 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 * The :func:`~django.views.decorators.http.condition` decorator for
conditional view processing now supports the ``If-unmodified-since`` header. conditional view processing now supports the ``If-unmodified-since`` header.

View File

@ -10,8 +10,8 @@ from unittest import skipIf
from django.conf import settings from django.conf import settings
from django.core import mail from django.core import mail
from django.http import ( from django.http import (
HttpRequest, HttpResponse, StreamingHttpResponse, HttpResponsePermanentRedirect, HttpRequest, HttpResponse, StreamingHttpResponse, FileResponse,
HttpResponseRedirect, HttpResponseRedirect, HttpResponsePermanentRedirect,
) )
from django.middleware.clickjacking import XFrameOptionsMiddleware from django.middleware.clickjacking import XFrameOptionsMiddleware
from django.middleware.common import CommonMiddleware, BrokenLinkEmailsMiddleware from django.middleware.common import CommonMiddleware, BrokenLinkEmailsMiddleware
@ -624,6 +624,20 @@ class GZipMiddlewareTest(TestCase):
self.assertEqual(r.get('Content-Encoding'), 'gzip') self.assertEqual(r.get('Content-Encoding'), 'gzip')
self.assertFalse(r.has_header('Content-Length')) 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): def test_compress_non_200_response(self):
""" """
Tests that compression is performed on responses with a status other than 200. Tests that compression is performed on responses with a status other than 200.

View File

@ -51,6 +51,28 @@ class WSGITest(TestCase):
bytes(response), bytes(response),
b"Content-Type: text/html; charset=utf-8\r\n\r\nHello World!") 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): class GetInternalWSGIApplicationTest(unittest.TestCase):
@override_settings(WSGI_APPLICATION="wsgi.wsgi.application") @override_settings(WSGI_APPLICATION="wsgi.wsgi.application")

View File

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