From 3d2cae0896ee8026d1c2c5d31e4c4c8f74f2fef4 Mon Sep 17 00:00:00 2001 From: Collin Anderson Date: Sat, 3 Jan 2015 12:06:24 -0500 Subject: [PATCH] Fixed #24072 -- Added FileResponse for streaming binary files. --- django/core/handlers/wsgi.py | 2 ++ django/http/__init__.py | 8 +++++--- django/http/response.py | 19 +++++++++++++++++++ django/views/static.py | 5 ++--- docs/ref/request-response.txt | 18 ++++++++++++++++++ docs/releases/1.8.txt | 2 ++ tests/middleware/tests.py | 18 ++++++++++++++++-- tests/wsgi/tests.py | 22 ++++++++++++++++++++++ tests/wsgi/urls.py | 3 ++- 9 files changed, 88 insertions(+), 9 deletions(-) diff --git a/django/core/handlers/wsgi.py b/django/core/handlers/wsgi.py index b947177bd1..b4402686c0 100644 --- a/django/core/handlers/wsgi.py +++ b/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 diff --git a/django/http/__init__.py b/django/http/__init__.py index fc5bd180ad..cf1e5d3950 100644 --- a/django/http/__init__.py +++ b/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', ] diff --git a/django/http/response.py b/django/http/response.py index 2735e3c59d..5168834b23 100644 --- a/django/http/response.py +++ b/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'] diff --git a/django/views/static.py b/django/views/static.py index 2998688284..75e81eccd3 100644 --- a/django/views/static.py +++ b/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 diff --git a/docs/ref/request-response.txt b/docs/ref/request-response.txt index 3f4d371df5..1375ba10e5 100644 --- a/docs/ref/request-response.txt +++ b/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')) diff --git a/docs/releases/1.8.txt b/docs/releases/1.8.txt index 12d0fbe69e..b69e69db49 100644 --- a/docs/releases/1.8.txt +++ b/docs/releases/1.8.txt @@ -559,6 +559,8 @@ Requests and Responses ` 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. diff --git a/tests/middleware/tests.py b/tests/middleware/tests.py index 22e2250e71..2b5b50f9e5 100644 --- a/tests/middleware/tests.py +++ b/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. diff --git a/tests/wsgi/tests.py b/tests/wsgi/tests.py index e8f29c60d8..13760d0b61 100644 --- a/tests/wsgi/tests.py +++ b/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") diff --git a/tests/wsgi/urls.py b/tests/wsgi/urls.py index 3ed0d5e8a8..e7505c717b 100644 --- a/tests/wsgi/urls.py +++ b/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'))), ]