diff --git a/CHANGELOG b/CHANGELOG index 42bfdef..395c494 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -11,8 +11,13 @@ future releases, check `milestones`_ and :doc:`/about/vision`. X-Sendfile support. - Feature #2 - Introduced support of Lighttpd's x-Sendfile. + - Feature #36 - Introduced support of Apache's mod_xsendfile. +- Feature #41 - ``django_downloadview.sendfile`` is a port of + `django-sendfile`'s ``sendfile`` function. The documentation contains notes + about migrating from `django-sendfile` to `django-downloadview`. + 1.4 (2013-11-24) ---------------- diff --git a/django_downloadview/api.py b/django_downloadview/api.py index fdd70f8..c768ad5 100644 --- a/django_downloadview/api.py +++ b/django_downloadview/api.py @@ -16,7 +16,7 @@ from django_downloadview.views import (PathDownloadView, # NoQA VirtualDownloadView, BaseDownloadView, DownloadMixin) -from django_downloadview.sendfile import sendfile # NoQA +from django_downloadview.shortcuts import sendfile # NoQA from django_downloadview.test import (assert_download_response, # NoQA setup_view, temporary_media_root) diff --git a/django_downloadview/response.py b/django_downloadview/response.py index 3135f35..a3e6ff5 100644 --- a/django_downloadview/response.py +++ b/django_downloadview/response.py @@ -57,7 +57,25 @@ def encode_basename_utf8(value): def content_disposition(filename): - """Return value of ``Content-Disposition`` header.""" + """Return value of ``Content-Disposition`` header with 'attachment'. + + >>> content_disposition('demo.txt') + 'attachment; filename=demo.txt' + + If filename is empty, only "attachment" is returned. + + >>> content_disposition('') + 'attachment' + + If filename contains non US-ASCII characters, the returned value contains + UTF-8 encoded filename and US-ASCII fallback. + + >>> content_disposition(unicode('é.txt', 'utf-8')) + "attachment; filename=e.txt; filename*=UTF-8''%C3%A9.txt" + + """ + if not filename: + return 'attachment' ascii_filename = encode_basename_ascii(filename) utf8_filename = encode_basename_utf8(filename) if ascii_filename == utf8_filename: # ASCII only. @@ -99,6 +117,15 @@ class DownloadResponse(StreamingHttpResponse): response (default implementation uses mimetypes, based on file name). + ``file_mimetype`` + Value for file's mimetype. If ``None`` (the default), then the file's + mimetype will be guessed via Python's :mod:`mimetypes`. See + :meth:`get_mime_type`. + + ``file_encoding`` + Value for file's encoding. If ``None`` (the default), then the file's + encoding will be guessed via Python's :mod:`mimetypes`. See + :meth:`get_encoding`. Here are some highlights to understand internal mechanisms and motivations: @@ -125,7 +152,8 @@ class DownloadResponse(StreamingHttpResponse): """ def __init__(self, file_instance, attachment=True, basename=None, - status=200, content_type=None): + status=200, content_type=None, file_mimetype=None, + file_encoding=None): """Constructor.""" self.file = file_instance super(DownloadResponse, self).__init__(streaming_content=self.file, @@ -135,6 +163,8 @@ class DownloadResponse(StreamingHttpResponse): self.attachment = attachment if not content_type: del self['Content-Type'] # Will be set later. + self.file_mimetype = file_mimetype + self.file_encoding = file_encoding # Apply default headers. for header, value in self.default_headers.items(): if not header in self: @@ -195,6 +225,8 @@ class DownloadResponse(StreamingHttpResponse): def get_mime_type(self): """Return mime-type of the file.""" + if self.file_mimetype is not None: + return self.file_mimetype default_mime_type = 'application/octet-stream' basename = self.get_basename() mime_type, encoding = mimetypes.guess_type(basename) @@ -202,6 +234,8 @@ class DownloadResponse(StreamingHttpResponse): def get_encoding(self): """Return encoding of the file to serve.""" + if self.file_encoding is not None: + return self.file_encoding basename = self.get_basename() mime_type, encoding = mimetypes.guess_type(basename) return encoding diff --git a/django_downloadview/sendfile.py b/django_downloadview/sendfile.py deleted file mode 100644 index 2031306..0000000 --- a/django_downloadview/sendfile.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -"""Port of django-sendfile in django-downloadview.""" -from django.conf import settings -from django.core.files.storage import FileSystemStorage - -from django_downloadview.views.storage import StorageDownloadView - - -def sendfile(request, filename, attachment=False, attachment_filename=None, - mimetype=None, encoding=None): - """Port of django-sendfile's API in django-downloadview. - - Instantiates a :class:`~django.core.files.storage.FileSystemStorage` with - ``settings.SENDFILE_ROOT`` as root folder. Then uses - :class:`StorageDownloadView` to stream the file by ``filename``. - - """ - storage = FileSystemStorage(location=settings.SENDFILE_ROOT) - view = StorageDownloadView().as_view(storage=storage, - path=filename, - attachment=attachment, - basename=attachment_filename) - return view(request) diff --git a/django_downloadview/shortcuts.py b/django_downloadview/shortcuts.py new file mode 100644 index 0000000..8fbebbc --- /dev/null +++ b/django_downloadview/shortcuts.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +"""Port of django-sendfile in django-downloadview.""" +from django_downloadview.views.path import PathDownloadView + + +def sendfile(request, filename, attachment=False, attachment_filename=None, + mimetype=None, encoding=None): + """Port of django-sendfile's API in django-downloadview. + + Instantiates a :class:`~django_downloadview.views.path.PathDownloadView` to + stream the file by ``filename``. + + """ + view = PathDownloadView.as_view(path=filename, + attachment=attachment, + basename=attachment_filename, + mimetype=mimetype, + encoding=encoding) + return view(request) diff --git a/django_downloadview/tests/views.py b/django_downloadview/tests/views.py index d2a1a83..5ccfb20 100644 --- a/django_downloadview/tests/views.py +++ b/django_downloadview/tests/views.py @@ -1,18 +1,22 @@ # -*- coding: utf-8 -*- """Unit tests around views.""" +import os import unittest try: from unittest import mock except ImportError: import mock +from django.core.files import File from django.http import Http404 from django.http.response import HttpResponseNotModified import django.test from django_downloadview import exceptions from django_downloadview.test import setup_view +from django_downloadview.response import DownloadResponse from django_downloadview import views +from django_downloadview.shortcuts import sendfile class DownloadMixinTestCase(unittest.TestCase): @@ -119,7 +123,9 @@ class DownloadMixinTestCase(unittest.TestCase): response_kwargs = {'dummy': 'value', 'file_instance': mock.sentinel.file_wrapper, 'attachment': True, - 'basename': None} + 'basename': None, + 'file_mimetype': None, + 'file_encoding': None} response = mixin.download_response(**response_kwargs) self.assertIs(response, mock.sentinel.response) response_factory.assert_called_once_with(**response_kwargs) # Not args @@ -199,6 +205,35 @@ class BaseDownloadViewTestCase(unittest.TestCase): view.render_to_response.assert_called_once_with() +class PathDownloadViewTestCase(unittest.TestCase): + "Tests for :class:`django_downloadviews.views.path.PathDownloadView`." + def test_get_file_ok(self): + "PathDownloadView.get_file() returns ``File`` instance." + view = setup_view(views.PathDownloadView(path=__file__), + 'fake request') + file_wrapper = view.get_file() + self.assertTrue(isinstance(file_wrapper, File)) + + def test_get_file_does_not_exist(self): + """PathDownloadView.get_file() raises FileNotFound if field does not + exist. + + """ + view = setup_view(views.PathDownloadView(path='i-do-no-exist'), + 'fake request') + with self.assertRaises(exceptions.FileNotFound): + view.get_file() + + def test_get_file_is_directory(self): + """PathDownloadView.get_file() raises FileNotFound if file is a + directory.""" + view = setup_view( + views.PathDownloadView(path=os.path.dirname(__file__)), + 'fake request') + with self.assertRaises(exceptions.FileNotFound): + view.get_file() + + class ObjectDownloadViewTestCase(unittest.TestCase): "Tests for :class:`django_downloadviews.views.object.ObjectDownloadView`." def test_get_file_ok(self): @@ -230,3 +265,38 @@ class ObjectDownloadViewTestCase(unittest.TestCase): view.object.other_field = None with self.assertRaises(exceptions.FileNotFound): view.get_file() + + +class SendfileTestCase(django.test.TestCase): + """Tests around :func:`django_downloadview.sendfile.sendfile`.""" + def test_defaults(self): + """sendfile() takes at least request and filename.""" + request = django.test.RequestFactory().get('/fake-url') + filename = __file__ + response = sendfile(request, filename) + self.assertTrue(isinstance(response, DownloadResponse)) + self.assertFalse(response.attachment) + + def test_custom(self): + """sendfile() accepts various arguments for response tuning.""" + request = django.test.RequestFactory().get('/fake-url') + filename = __file__ + response = sendfile(request, + filename, + attachment=True, + attachment_filename='toto.txt', + mimetype='test/octet-stream', + encoding='gzip') + self.assertTrue(isinstance(response, DownloadResponse)) + self.assertTrue(response.attachment) + self.assertEqual(response.basename, 'toto.txt') + self.assertEqual(response['Content-Type'], + 'test/octet-stream; charset=utf-8') + self.assertEqual(response.get_encoding(), 'gzip') + + def test_404(self): + """sendfile() raises Http404 if file does not exists.""" + request = django.test.RequestFactory().get('/fake-url') + filename = 'i-do-no-exist' + with self.assertRaises(Http404): + sendfile(request, filename) diff --git a/django_downloadview/views/base.py b/django_downloadview/views/base.py index 809f8c4..58fb04f 100644 --- a/django_downloadview/views/base.py +++ b/django_downloadview/views/base.py @@ -33,6 +33,16 @@ class DownloadMixin(object): #: Client-side filename, if only file is returned as attachment. basename = None + #: File's mime type. + #: If ``None`` (the default), then the file's mime type will be guessed via + #: :mod:`mimetypes`. + mimetype = None + + #: File's encoding. + #: If ``None`` (the default), then the file's encoding will be guessed via + #: :mod:`mimetypes`. + encoding = None + def get_file(self): """Return a file wrapper instance. @@ -43,8 +53,29 @@ class DownloadMixin(object): raise NotImplementedError() def get_basename(self): + """Return :attr:`basename`. + + Override this method if you need more dynamic basename. + + """ return self.basename + def get_mimetype(self): + """Return :attr:`mimetype`. + + Override this method if you need more dynamic mime type. + + """ + return self.mimetype + + def get_encoding(self): + """Return :attr:`encoding`. + + Override this method if you need more dynamic encoding. + + """ + return self.encoding + def was_modified_since(self, file_instance, since): """Return True if ``file_instance`` was modified after ``since``. @@ -81,6 +112,8 @@ class DownloadMixin(object): response_kwargs.setdefault('file_instance', self.file_instance) response_kwargs.setdefault('attachment', self.attachment) response_kwargs.setdefault('basename', self.get_basename()) + response_kwargs.setdefault('file_mimetype', self.get_mimetype()) + response_kwargs.setdefault('file_encoding', self.get_encoding()) response = self.response_class(*response_args, **response_kwargs) return response diff --git a/django_downloadview/views/path.py b/django_downloadview/views/path.py index 487fd8a..2a5d09d 100644 --- a/django_downloadview/views/path.py +++ b/django_downloadview/views/path.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- """:class:`PathDownloadView`.""" +import os + from django.core.files import File +from django_downloadview.exceptions import FileNotFound from django_downloadview.views.base import BaseDownloadView @@ -30,4 +33,7 @@ class PathDownloadView(BaseDownloadView): def get_file(self): """Use path to return wrapper around file to serve.""" + filename = self.get_path() + if not os.path.isfile(filename): + raise FileNotFound('File "{0}" does not exists'.format(filename)) return File(open(self.get_path(), 'rb')) diff --git a/docs/about/alternatives.txt b/docs/about/alternatives.txt index d66c64b..9445750 100644 --- a/docs/about/alternatives.txt +++ b/docs/about/alternatives.txt @@ -30,6 +30,11 @@ django-sendfile `django-sendfile`_ is a wrapper around web-server specific methods for sending files to web clients. +.. note:: + + :func:`django_downloadview.shortcuts.sendfile` is a port of + `django-sendfile`'s main function. See :doc:`/django-sendfile` for details. + ``django-senfile``'s main focus is simplicity: API is made of a single ``sendfile()`` function you call inside your views: @@ -64,12 +69,6 @@ Here are main differences between the two projects: root folder. Whereas ``django-downloadview``'s ``DownloadDispatcherMiddleware`` supports multiple configurations. -As of 2012-04-11, ``django-sendfile`` (version 0.3.2) seems quite popular and -may be a good alternative **provided you serve files that live in a single -directory of local filesystem**. - -:func:`django_downloadview.sendfile` is a port of django-sendfile's main function. - .. rubric:: References diff --git a/docs/django-sendfile.txt b/docs/django-sendfile.txt new file mode 100644 index 0000000..63a2eb1 --- /dev/null +++ b/docs/django-sendfile.txt @@ -0,0 +1,57 @@ +############################## +Migrating from django-sendfile +############################## + +`django-sendfile`_ is a wrapper around web-server specific methods for sending +files to web clients. See :doc:`/about/alternatives` for details about this +project. + +`django-downloadview` provides a :func:`port of django-sendfile's main function +`. + +.. warning:: + + `django-downloadview` can replace the following `django-sendfile`'s + backends: ``nginx``, ``xsendfile``, ``simple``. But it currently cannot + replace ``mod_wsgi`` backend. + +Here are tips to migrate from `django-sendfile` to `django-downloadview`... + +1. In your project's and apps dependencies, replace ``django-sendfile`` by + ``django-downloadview``. + +2. In your Python scripts, replace ``import sendfile`` and ``from sendfile`` + by ``import django_downloadview`` and ``from django_downloadview``. + You get something like ``from django_downloadview import sendfile`` + +3. Adapt your settings as explained in :doc:`/settings`. Pay attention to: + + * replace ``sendfile`` by ``django_downloadview`` in ``INSTALLED_APPS``. + * replace ``SENDFILE_BACKEND`` by ``DOWNLOADVIEW_BACKEND`` + * setup ``DOWNLOADVIEW_RULES``. It replaces ``SENDFILE_ROOT`` and can do + more. + * register ``django_downloadview.SmartDownloadMiddleware`` in + ``MIDDLEWARE_CLASSES``. + +4. Change your tests if any. You can no longer use `django-senfile`'s + ``development`` backend. See :doc:`/testing` for `django-downloadview`'s + toolkit. + +5. Here you are! ... or please report your story/bug at `django-downloadview's + bugtracker`_ ;) + + +************* +API reference +************* + +.. autofunction:: django_downloadview.shortcuts.sendfile + + +.. rubric:: References + +.. target-notes:: + +.. _`django-sendfile`: http://pypi.python.org/pypi/django-sendfile +.. _`django-downloadview's bugtracker`: + https://github.com/benoitbryon/django-downloadview/issues diff --git a/docs/index.txt b/docs/index.txt index 5c0095f..e5150a7 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -18,6 +18,7 @@ Contents healthchecks files responses + django-sendfile demo about/index dev