From 47fc5bc9e378c885e43ac28a75aebd5679822904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Fri, 26 Jul 2013 01:38:38 +0200 Subject: [PATCH 01/25] Enhanced buildout configuration for development environment: using crate.io, upgraded to python-termstyle 0.1.10. --- .gitignore | 1 + etc/buildout.cfg | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index e2bbb0c..9a43def 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ # Editors' temporary buffers. .*.swp +*~ diff --git a/etc/buildout.cfg b/etc/buildout.cfg index 39d958f..2d04b51 100644 --- a/etc/buildout.cfg +++ b/etc/buildout.cfg @@ -1,8 +1,7 @@ # Buildout configuration file to deploy a development environment. [buildout] -extensions = - buildout-versions +extensions = buildout-versions versions = versions # Configure directories: put buildout generated files in lib/buildout instead # of in current directory. @@ -13,9 +12,11 @@ installed = lib/buildout/.installed.cfg parts-directory = lib/buildout/parts # Package index, mirrors, allowed hosts and dependency links. Those options # control locations where buildout looks for packages. -index = http://f.pypi.python.org/simple +index = https://simple.crate.io find-links = -allow-hosts = *.python.org +allow-hosts = + *.crate.io + packages.crate-cdn.com use-dependency-links = false # Development. develop = @@ -69,7 +70,7 @@ Jinja2 = 2.6 mock = 1.0.1 nose = 1.2.1 Pygments = 1.6 -python-termstyle = 0.1.9 +python-termstyle = 0.1.10 rednose = 0.3 requests = 1.2.0 Sphinx = 1.1.3 From 588b1b0a6eec403c8bd0322e24a05557feb1d093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Fri, 4 Oct 2013 18:11:16 +0200 Subject: [PATCH 02/25] Introduced DownloadDispatcherMiddleware and settings.DOWNLOADVIEW_MIDDLEWARES. Deprecated most options for former global XAccelRedirectMiddleware. Splitted nginx module into a package. Introduced BaseDownloadMiddleware. --- CHANGELOG | 9 +- INSTALL | 22 +- README | 6 +- demo/demoproject/download/views.py | 2 +- demo/demoproject/settings.py | 10 +- django_downloadview/__init__.py | 24 +- django_downloadview/decorators.py | 2 +- django_downloadview/middlewares.py | 127 ++++++- django_downloadview/nginx.py | 415 ----------------------- django_downloadview/nginx/__init__.py | 15 + django_downloadview/nginx/decorators.py | 12 + django_downloadview/nginx/middlewares.py | 116 +++++++ django_downloadview/nginx/response.py | 34 ++ django_downloadview/nginx/settings.py | 139 ++++++++ django_downloadview/nginx/tests.py | 119 +++++++ django_downloadview/response.py | 13 +- django_downloadview/test.py | 2 +- django_downloadview/utils.py | 17 +- docs/api/django_downloadview.nginx.txt | 51 +++ docs/api/django_downloadview.txt | 15 +- docs/index.txt | 1 + docs/optimizations/index.txt | 35 +- docs/settings.txt | 39 +++ 23 files changed, 764 insertions(+), 461 deletions(-) delete mode 100644 django_downloadview/nginx.py create mode 100644 django_downloadview/nginx/__init__.py create mode 100644 django_downloadview/nginx/decorators.py create mode 100644 django_downloadview/nginx/middlewares.py create mode 100644 django_downloadview/nginx/response.py create mode 100644 django_downloadview/nginx/settings.py create mode 100644 django_downloadview/nginx/tests.py create mode 100644 docs/api/django_downloadview.nginx.txt create mode 100644 docs/settings.txt diff --git a/CHANGELOG b/CHANGELOG index 6a8489b..fc6b5f9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,8 +4,15 @@ Changelog 1.3 (unreleased) ---------------- -- Nothing changed yet. +Big refactoring around Nginx middleware. +- Feature #?? - Introduced DownloadDispatcherMiddleware that iterates over a + list of configurable download middlewares. Allows to plug several download + middlewares with different configurations. Deprecates the settings related + to previous single-and-global middleware. +- Refactoring #?? - Splitted nginx module in a package. +- Refactoring #?? - Expose most Python API directly in `django_downloadview` + package. Simplifies imports in client applications. 1.2 (2013-05-28) ---------------- diff --git a/INSTALL b/INSTALL index 9853057..d8b4f9c 100644 --- a/INSTALL +++ b/INSTALL @@ -2,16 +2,22 @@ Installation ############ -This project is open-source, published under BSD license. +`django-downloadview` project is open-source, published under BSD license. See :doc:`/about/license` for details. -If you want to install a development environment, you should go to :doc:`/dev` -documentation. +.. note:: + + If you want to install a development environment, please see :doc:`/dev` + documentation. + +System requirements: + +* Python 2.7 Install the package with your favorite Python installer. As an example, with pip: -.. code-block:: sh +.. code:: sh pip install django-downloadview @@ -28,3 +34,11 @@ Next, you'll have to setup some download view(s). See :doc:`demo project Optionally, you may setup additional :doc:`server optimizations `. + + +.. rubric:: Notes & references + +.. seealso:: + + * :doc:`/settings` + * :doc:`/about/changelog` diff --git a/README b/README index 50ff0c5..f0d0d76 100644 --- a/README +++ b/README @@ -2,10 +2,10 @@ django-downloadview ################### -Django-DownloadView provides generic views to make Django serve files. +`django-downloadview` makes it easy to serve files with Django. -It can serve files from models, storages, local filesystem, arbitrary URL... -and even generated files. +It provides generic views to serve files from models, storages, local +filesystem, arbitrary URL... and even generated files. For increased performances, it can delegate the actual streaming to a reverse proxy, via mechanisms such as Nginx's X-Accel. diff --git a/demo/demoproject/download/views.py b/demo/demoproject/download/views.py index cd106cd..94b8caf 100644 --- a/demo/demoproject/download/views.py +++ b/demo/demoproject/download/views.py @@ -86,5 +86,5 @@ download_generated_hello_world = StringIODownloadView.as_view() download_http_hello_world = views.HTTPDownloadView.as_view( - url=u'https://raw.github.com/benoitbryon/django-downloadview/master/demo/demoproject/download/fixtures/hello-world.txt', + url=u'http://localhost:8000/download/hello-world.txt', basename=u'hello-world.txt') diff --git a/demo/demoproject/settings.py b/demo/demoproject/settings.py index bc9149f..f647ed1 100755 --- a/demo/demoproject/settings.py +++ b/demo/demoproject/settings.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Django settings for Django-DownloadView demo project.""" from os.path import abspath, dirname, join @@ -63,9 +64,12 @@ MIDDLEWARE_CLASSES = [ # Uncomment the following lines to enable global Nginx optimizations. -#MIDDLEWARE_CLASSES.append('django_downloadview.nginx.XAccelRedirectMiddleware') -#NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT = MEDIA_ROOT -#NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL = "/proxied-download" +#MIDDLEWARE_CLASSES.append('django_downloadview.DownloadDispatcherMiddleware') +DOWNLOADVIEW_MIDDLEWARES = ( + ('default', 'django_downloadview.nginx.XAccelRedirectMiddleware', + {'source_dir': MEDIA_ROOT, + 'destination_url': '/proxied-download'}), +) # Development configuration. diff --git a/django_downloadview/__init__.py b/django_downloadview/__init__.py index f92cfe0..f74eee2 100644 --- a/django_downloadview/__init__.py +++ b/django_downloadview/__init__.py @@ -1,13 +1,19 @@ """django-downloadview provides generic download views for Django.""" -# Shortcut import. -from django_downloadview.views import (PathDownloadView, - ObjectDownloadView, - StorageDownloadView, - VirtualDownloadView) +import pkg_resources -pkg_resources = __import__('pkg_resources') -distribution = pkg_resources.get_distribution('django-downloadview') - #: Module version, as defined in PEP-0396. -__version__ = distribution.version +__version__ = pkg_resources.get_distribution(__package__.replace('-', '_')) \ + .version + + +# API shortcuts. +from django_downloadview.response import DownloadResponse # NoQA +from django_downloadview.middlewares import ( # NoQA + BaseDownloadMiddleware, + DownloadDispatcherMiddleware) +from django_downloadview.nginx import XAccelRedirectMiddleware # NoQA +from django_downloadview.views import (PathDownloadView, # NoQA + ObjectDownloadView, # NoQA + StorageDownloadView, # NoQA + VirtualDownloadView) # NoQA diff --git a/django_downloadview/decorators.py b/django_downloadview/decorators.py index 9d1a1bc..f231870 100644 --- a/django_downloadview/decorators.py +++ b/django_downloadview/decorators.py @@ -11,7 +11,7 @@ class DownloadDecorator(object): Middleware instance is built from ``middleware_factory`` with ``*args`` and ``**kwargs``. Middleware factory is typically a class, such as some - :py:class:`django_downloadview.middlewares.XAccelMiddleware` subclass. + :py:class:`django_downloadview.BaseDownloadMiddleware` subclass. Response is built from view, then the middleware's ``process_response`` method is applied on response. diff --git a/django_downloadview/middlewares.py b/django_downloadview/middlewares.py index 1dd96fb..8d02503 100644 --- a/django_downloadview/middlewares.py +++ b/django_downloadview/middlewares.py @@ -1,5 +1,25 @@ -"""Base material for download middlewares.""" -from django_downloadview.response import is_download_response +# -*- coding: utf-8 -*- +"""Base material for download middlewares. + +Download middlewares capture :py:class:`django_downloadview.DownloadResponse` +responses and may replace them with optimized download responses. + +""" +import os + +from django.conf import settings + +from django_downloadview.response import DownloadResponse + + +def is_download_response(response): + """Return ``True`` if ``response`` is a download response. + + Current implementation returns True if ``response`` is an instance of + :py:class:`django_downloadview.response.DownloadResponse`. + + """ + return isinstance(response, DownloadResponse) class BaseDownloadMiddleware(object): @@ -12,14 +32,14 @@ class BaseDownloadMiddleware(object): """Return True if ``response`` can be considered as a file download. By default, this method uses - :py:func:`django_downloadview.response.is_download_response`. + :py:func:`django_downloadview.middlewares.is_download_response`. Override this method if you want a different behaviour. """ return is_download_response(response) def process_response(self, request, response): - """Call :py:meth:`process_download_response` if ``response`` is download.""" + """Call `process_download_response()` if ``response`` is download.""" if self.is_download_response(response): return self.process_download_response(request, response) return response @@ -27,3 +47,102 @@ class BaseDownloadMiddleware(object): def process_download_response(self, request, response): """Handle file download response.""" raise NotImplementedError() + + +class RealDownloadMiddleware(BaseDownloadMiddleware): + """Download middleware that cannot handle virtual files.""" + def is_download_response(self, response): + """Return True for DownloadResponse, except for "virtual" files. + + This implementation cannot handle files that live in memory or which + are to be dynamically iterated over. So, we capture only responses + whose file attribute have either an URL or a file name. + + """ + if super(RealDownloadMiddleware, self).is_download_response(response): + try: + return response.file.url or response.file.name + except AttributeError: + return False + else: + return True + return False + + +class DownloadDispatcherMiddleware(BaseDownloadMiddleware): + """Download middleware that dispatches job to several middlewares. + + The list of Children middlewares is read in `DOWNLOADVIEW_MIDDLEWARES` + setting. + + """ + def __init__(self): + #: List of children middlewares. + self.middlewares = [] + self.load_middlewares_from_settings() + + def load_middlewares_from_settings(self): + for (key, import_string, kwargs) in getattr(settings, + 'DOWNLOADVIEW_MIDDLEWARES', + []): + if ':' in import_string: + module_string, attr_string = import_string.split(':', 1) + else: + module_string, attr_string = import_string.rsplit('.', 1) + module = __import__(module_string, globals(), locals(), + [attr_string], -1) + factory = getattr(module, attr_string) + middleware = factory(**kwargs) + self.middlewares.append((key, middleware)) + + def process_download_response(self, request, response): + """Dispatches job to children middlewares.""" + for (key, middleware) in self.middlewares: + response = middleware.process_response(request, response) + return response + + +class ProxiedDownloadMiddleware(RealDownloadMiddleware): + """Base class for middlewares that use optimizations of reverse proxies.""" + def __init__(self, source_dir=None, source_url=None, destination_url=None): + """Constructor.""" + self.source_dir = source_dir + self.source_url = source_url + self.destination_url = destination_url + + def get_redirect_url(self, response): + """Return redirect URL for file wrapped into response.""" + url = None + file_url = '' + if self.source_url is not None: + try: + file_url = response.file.url + except AttributeError: + pass + else: + if file_url.startswith(self.source_url): + file_url = file_url[len(self.source_url):] + url = file_url + file_name = '' + if url is None and self.source_dir is not None: + try: + file_name = response.file.name + except AttributeError: + pass + else: + if file_name.startswith(self.source_dir): + file_name = os.path.relpath(file_name, self.source_dir) + url = file_name.replace(os.path.sep, '/') + if url is None: + message = ("""Couldn't capture/convert file attributes into a """ + """redirection. """ + """``source_url`` is "%(source_url)s", """ + """file's URL is "%(file_url)s". """ + """``source_dir`` is "%(source_dir)s", """ + """file's name is "%(file_name)s". """ + % {'source_url': self.source_url, + 'file_url': file_url, + 'source_dir': self.source_dir, + 'file_name': file_name}) + raise Exception(message) + return '/'.join((self.destination_url.rstrip('/'), url.lstrip('/'))) diff --git a/django_downloadview/nginx.py b/django_downloadview/nginx.py deleted file mode 100644 index 9f5a0e8..0000000 --- a/django_downloadview/nginx.py +++ /dev/null @@ -1,415 +0,0 @@ -"""Optimizations for Nginx. - -See also `Nginx X-accel documentation `_ and -:doc:`narrative documentation about Nginx optimizations -`. - -""" -from datetime import datetime, timedelta -import os -import warnings - -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from django.http import HttpResponse - -from django_downloadview.decorators import DownloadDecorator -from django_downloadview.middlewares import BaseDownloadMiddleware -from django_downloadview.utils import content_type_to_charset - - -#: Default value for X-Accel-Buffering header. -#: Also default value for -#: ``settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING``. -#: -#: See http://wiki.nginx.org/X-accel#X-Accel-Limit-Buffering -#: -#: Default value is None, which means "let Nginx choose", i.e. use Nginx -#: defaults or specific configuration. -#: -#: If set to ``False``, Nginx buffering is disabled. -#: If set to ``True``, Nginx buffering is enabled. -DEFAULT_WITH_BUFFERING = None -if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING'): - setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING', - DEFAULT_WITH_BUFFERING) - - -#: Default value for X-Accel-Limit-Rate header. -#: Also default value for ``settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE``. -#: -#: See http://wiki.nginx.org/X-accel#X-Accel-Limit-Rate -#: -#: Default value is None, which means "let Nginx choose", i.e. use Nginx -#: defaults or specific configuration. -#: -#: If set to ``False``, Nginx limit rate is disabled. -#: Else, it indicates the limit rate in bytes. -DEFAULT_LIMIT_RATE = None -if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE'): - setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE', - DEFAULT_LIMIT_RATE) - - -#: Default value for X-Accel-Limit-Expires header. -#: Also default value for ``settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES``. -#: -#: See http://wiki.nginx.org/X-accel#X-Accel-Limit-Expires -#: -#: Default value is None, which means "let Nginx choose", i.e. use Nginx -#: defaults or specific configuration. -#: -#: If set to ``False``, Nginx buffering is disabled. -#: Else, it indicates the expiration delay, in seconds. -DEFAULT_EXPIRES = None -if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES'): - setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES', DEFAULT_EXPIRES) - - -#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR. -DEFAULT_SOURCE_DIR = settings.MEDIA_ROOT -if hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT'): - warnings.warn("settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT is " - "deprecated, use " - "settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR instead.", - DeprecationWarning) - DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT -if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR'): - setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR', - DEFAULT_SOURCE_DIR) - - -#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL. -DEFAULT_SOURCE_URL = settings.MEDIA_URL -if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL'): - setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL', - DEFAULT_SOURCE_URL) - - -#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL. -DEFAULT_DESTINATION_URL = None -if hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL'): - warnings.warn("settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL is " - "deprecated, use " - "settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL " - "instead.", - DeprecationWarning) - DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL -if not hasattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL'): - setattr(settings, 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL', - DEFAULT_DESTINATION_URL) - - -class XAccelRedirectResponse(HttpResponse): - """Http response that delegates serving file to Nginx.""" - def __init__(self, redirect_url, content_type, basename=None, expires=None, - with_buffering=None, limit_rate=None, attachment=True): - """Return a HttpResponse with headers for Nginx X-Accel-Redirect.""" - super(XAccelRedirectResponse, self).__init__(content_type=content_type) - if attachment: - self.basename = basename or redirect_url.split('/')[-1] - self['Content-Disposition'] = 'attachment; filename={name}'.format( - name=self.basename) - self['X-Accel-Redirect'] = redirect_url - self['X-Accel-Charset'] = content_type_to_charset(content_type) - if with_buffering is not None: - self['X-Accel-Buffering'] = with_buffering and 'yes' or 'no' - if expires: - expire_seconds = timedelta(expires - datetime.now()).seconds - self['X-Accel-Expires'] = expire_seconds - elif expires is not None: # We explicitely want it off. - self['X-Accel-Expires'] = 'off' - if limit_rate is not None: - self['X-Accel-Limit-Rate'] = (limit_rate - and '%d' % limit_rate - or 'off') - - -class XAccelRedirectValidator(object): - """Utility class to validate XAccelRedirectResponse instances. - - See also :py:func:`assert_x_accel_redirect` shortcut function. - - """ - def __call__(self, test_case, response, **assertions): - """Assert that ``response`` is a valid X-Accel-Redirect response. - - Optional ``assertions`` dictionary can be used to check additional - items: - - * ``basename``: the basename of the file in the response. - - * ``content_type``: the value of "Content-Type" header. - - * ``redirect_url``: the value of "X-Accel-Redirect" header. - - * ``charset``: the value of ``X-Accel-Charset`` header. - - * ``with_buffering``: the value of ``X-Accel-Buffering`` header. - If ``False``, then makes sure that the header disables buffering. - If ``None``, then makes sure that the header is not set. - - * ``expires``: the value of ``X-Accel-Expires`` header. - If ``False``, then makes sure that the header disables expiration. - If ``None``, then makes sure that the header is not set. - - * ``limit_rate``: the value of ``X-Accel-Limit-Rate`` header. - If ``False``, then makes sure that the header disables limit rate. - If ``None``, then makes sure that the header is not set. - - """ - self.assert_x_accel_redirect_response(test_case, response) - for key, value in assertions.iteritems(): - assert_func = getattr(self, 'assert_%s' % key) - assert_func(test_case, response, value) - - def assert_x_accel_redirect_response(self, test_case, response): - test_case.assertTrue(isinstance(response, XAccelRedirectResponse)) - - def assert_basename(self, test_case, response, value): - test_case.assertEqual(response.basename, value) - - def assert_content_type(self, test_case, response, value): - test_case.assertEqual(response['Content-Type'], value) - - def assert_redirect_url(self, test_case, response, value): - test_case.assertEqual(response['X-Accel-Redirect'], value) - - def assert_charset(self, test_case, response, value): - test_case.assertEqual(response['X-Accel-Charset'], value) - - def assert_with_buffering(self, test_case, response, value): - header = 'X-Accel-Buffering' - if value is None: - test_case.assertFalse(header in response) - elif value: - test_case.assertEqual(header, 'yes') - else: - test_case.assertEqual(header, 'no') - - def assert_expires(self, test_case, response, value): - header = 'X-Accel-Expires' - if value is None: - test_case.assertFalse(header in response) - elif not value: - test_case.assertEqual(header, 'off') - else: - test_case.assertEqual(header, value) - - def assert_limit_rate(self, test_case, response, value): - header = 'X-Accel-Limit-Rate' - if value is None: - test_case.assertFalse(header in response) - elif not value: - test_case.assertEqual(header, 'off') - else: - test_case.assertEqual(header, value) - - def assert_attachment(self, test_case, response, value): - header = 'Content-Disposition' - if value: - test_case.assertTrue(response[header].startswith('attachment')) - else: - test_case.assertFalse(header in response) - - -def assert_x_accel_redirect(test_case, response, **assertions): - """Make ``test_case`` assert that ``response`` is a XAccelRedirectResponse. - - Optional ``assertions`` dictionary can be used to check additional items: - - * ``basename``: the basename of the file in the response. - - * ``content_type``: the value of "Content-Type" header. - - * ``redirect_url``: the value of "X-Accel-Redirect" header. - - * ``charset``: the value of ``X-Accel-Charset`` header. - - * ``with_buffering``: the value of ``X-Accel-Buffering`` header. - If ``False``, then makes sure that the header disables buffering. - If ``None``, then makes sure that the header is not set. - - * ``expires``: the value of ``X-Accel-Expires`` header. - If ``False``, then makes sure that the header disables expiration. - If ``None``, then makes sure that the header is not set. - - * ``limit_rate``: the value of ``X-Accel-Limit-Rate`` header. - If ``False``, then makes sure that the header disables limit rate. - If ``None``, then makes sure that the header is not set. - - """ - validator = XAccelRedirectValidator() - return validator(test_case, response, **assertions) - - -class BaseXAccelRedirectMiddleware(BaseDownloadMiddleware): - """Configurable middleware, for use in decorators or in global middlewares. - - Standard Django middlewares are configured globally via settings. Instances - of this class are to be configured individually. It makes it possible to - use this class as the factory in - :py:class:`django_downloadview.decorators.DownloadDecorator`. - - """ - def __init__(self, source_dir=None, source_url=None, destination_url=None, - expires=None, with_buffering=None, limit_rate=None, - media_root=None, media_url=None): - """Constructor.""" - if media_url is not None: - warnings.warn("%s ``media_url`` is deprecated. Use " - "``destination_url`` instead." - % self.__class__.__name__, - DeprecationWarning) - if destination_url is None: - self.destination_url = media_url - else: - self.destination_url = destination_url - else: - self.destination_url = destination_url - if media_root is not None: - warnings.warn("%s ``media_root`` is deprecated. Use " - "``source_dir`` instead." % self.__class__.__name__, - DeprecationWarning) - if source_dir is None: - self.source_dir = media_root - else: - self.source_dir = source_dir - else: - self.source_dir = source_dir - self.source_url = source_url - self.expires = expires - self.with_buffering = with_buffering - self.limit_rate = limit_rate - - def is_download_response(self, response): - """Return True for DownloadResponse, except for "virtual" files. - - This implementation can't handle files that live in memory or which are - to be dynamically iterated over. So, we capture only responses whose - file attribute have either an URL or a file name. - - """ - if super(BaseXAccelRedirectMiddleware, - self).is_download_response(response): - try: - response.file.url - except AttributeError: - try: - response.file.name - except AttributeError: - return False - return True - return False - - def get_redirect_url(self, response): - """Return redirect URL for file wrapped into response.""" - url = None - file_url = '' - if self.source_url is not None: - try: - file_url = response.file.url - except AttributeError: - pass - else: - if file_url.startswith(self.source_url): - file_url = file_url[len(self.source_url):] - url = file_url - file_name = '' - if url is None and self.source_dir is not None: - try: - file_name = response.file.name - except AttributeError: - pass - else: - if file_name.startswith(self.source_dir): - file_name = os.path.relpath(file_name, self.source_dir) - url = file_name.replace(os.path.sep, '/') - if url is None: - message = ("""Couldn't capture/convert file attributes into a """ - """redirection. """ - """``source_url`` is "%(source_url)s", """ - """file's URL is "%(file_url)s". """ - """``source_dir`` is "%(source_dir)s", """ - """file's name is "%(file_name)s". """ - % {'source_url': self.source_url, - 'file_url': file_url, - 'source_dir': self.source_dir, - 'file_name': file_name}) - raise ImproperlyConfigured(message) - return '/'.join((self.destination_url.rstrip('/'), url.lstrip('/'))) - - def process_download_response(self, request, response): - """Replace DownloadResponse instances by NginxDownloadResponse ones.""" - redirect_url = self.get_redirect_url(response) - if self.expires: - expires = self.expires - else: - try: - expires = response.expires - except AttributeError: - expires = None - return XAccelRedirectResponse(redirect_url=redirect_url, - content_type=response['Content-Type'], - basename=response.basename, - expires=expires, - with_buffering=self.with_buffering, - limit_rate=self.limit_rate, - attachment=response.attachment) - - -class XAccelRedirectMiddleware(BaseXAccelRedirectMiddleware): - """Apply X-Accel-Redirect globally, via Django settings. - - Available settings are: - - NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL: - The string at the beginning of URLs to replace with - ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. - If ``None``, then URLs aren't captured. - Defaults to ``settings.MEDIA_URL``. - - NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR: - The string at the beginning of filenames (path) to replace with - ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. - If ``None``, then filenames aren't captured. - Defaults to ``settings.MEDIA_ROOT``. - - NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL: - The base URL where requests are proxied to. - If ``None`` an ImproperlyConfigured exception is raised. - - .. note:: - - The following settings are deprecated since version 1.1. - URLs can be used as redirection source since 1.1, and then "MEDIA_ROOT" - and "MEDIA_URL" became too confuse. - - NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT: - Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR``. - - NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL: - Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. - - """ - def __init__(self): - """Use Django settings as configuration.""" - if settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is None: - raise ImproperlyConfigured( - 'settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is ' - 'required by %s middleware' % self.__class__.__name__) - super(XAccelRedirectMiddleware, self).__init__( - source_dir=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR, - source_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL, - destination_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL, - expires=settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES, - with_buffering=settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING, - limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE) - - -#: Apply BaseXAccelRedirectMiddleware to ``view_func`` response. -#: -#: Proxies additional arguments (``*args``, ``**kwargs``) to -#: :py:class:`BaseXAccelRedirectMiddleware` constructor (``expires``, -#: ``with_buffering``, and ``limit_rate``). -x_accel_redirect = DownloadDecorator(BaseXAccelRedirectMiddleware) diff --git a/django_downloadview/nginx/__init__.py b/django_downloadview/nginx/__init__.py new file mode 100644 index 0000000..26b5053 --- /dev/null +++ b/django_downloadview/nginx/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +"""Optimizations for Nginx. + +See also `Nginx X-accel documentation `_ and +:doc:`narrative documentation about Nginx optimizations +`. + +""" +# API shortcuts. +from django_downloadview.nginx.decorators import x_accel_redirect # NoQA +from django_downloadview.nginx.response import XAccelRedirectResponse # NoQA +from django_downloadview.nginx.tests import assert_x_accel_redirect # NoQA +from django_downloadview.nginx.middlewares import ( # NoQA + XAccelRedirectMiddleware, + SingleXAccelRedirectMiddleware) diff --git a/django_downloadview/nginx/decorators.py b/django_downloadview/nginx/decorators.py new file mode 100644 index 0000000..59c5941 --- /dev/null +++ b/django_downloadview/nginx/decorators.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +"""Decorators to apply Nginx X-Accel on a specific view.""" +from django_downloadview.decorators import DownloadDecorator +from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware + + +#: Apply BaseXAccelRedirectMiddleware to ``view_func`` response. +#: +#: Proxies additional arguments (``*args``, ``**kwargs``) to +#: :py:class:`BaseXAccelRedirectMiddleware` constructor (``expires``, +#: ``with_buffering``, and ``limit_rate``). +x_accel_redirect = DownloadDecorator(XAccelRedirectMiddleware) diff --git a/django_downloadview/nginx/middlewares.py b/django_downloadview/nginx/middlewares.py new file mode 100644 index 0000000..c6b101f --- /dev/null +++ b/django_downloadview/nginx/middlewares.py @@ -0,0 +1,116 @@ +import warnings + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +from django_downloadview.middlewares import ProxiedDownloadMiddleware +from django_downloadview.nginx.response import XAccelRedirectResponse + + +class XAccelRedirectMiddleware(ProxiedDownloadMiddleware): + """Configurable middleware, for use in decorators or in global middlewares. + + Standard Django middlewares are configured globally via settings. Instances + of this class are to be configured individually. It makes it possible to + use this class as the factory in + :py:class:`django_downloadview.decorators.DownloadDecorator`. + + """ + def __init__(self, source_dir=None, source_url=None, destination_url=None, + expires=None, with_buffering=None, limit_rate=None, + media_root=None, media_url=None): + """Constructor.""" + if media_url is not None: + warnings.warn("%s ``media_url`` is deprecated. Use " + "``destination_url`` instead." + % self.__class__.__name__, + DeprecationWarning) + if destination_url is None: + destination_url = media_url + else: + destination_url = destination_url + else: + destination_url = destination_url + if media_root is not None: + warnings.warn("%s ``media_root`` is deprecated. Use " + "``source_dir`` instead." % self.__class__.__name__, + DeprecationWarning) + if source_dir is None: + source_dir = media_root + else: + source_dir = source_dir + else: + source_dir = source_dir + super(XAccelRedirectMiddleware, self).__init__(source_dir, + source_url, + destination_url) + self.expires = expires + self.with_buffering = with_buffering + self.limit_rate = limit_rate + + def process_download_response(self, request, response): + """Replace DownloadResponse instances by NginxDownloadResponse ones.""" + redirect_url = self.get_redirect_url(response) + if self.expires: + expires = self.expires + else: + try: + expires = response.expires + except AttributeError: + expires = None + return XAccelRedirectResponse(redirect_url=redirect_url, + content_type=response['Content-Type'], + basename=response.basename, + expires=expires, + with_buffering=self.with_buffering, + limit_rate=self.limit_rate, + attachment=response.attachment) + + +class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware): + """Apply X-Accel-Redirect globally, via Django settings. + + Available settings are: + + NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL: + The string at the beginning of URLs to replace with + ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. + If ``None``, then URLs aren't captured. + Defaults to ``settings.MEDIA_URL``. + + NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR: + The string at the beginning of filenames (path) to replace with + ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. + If ``None``, then filenames aren't captured. + Defaults to ``settings.MEDIA_ROOT``. + + NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL: + The base URL where requests are proxied to. + If ``None`` an ImproperlyConfigured exception is raised. + + .. note:: + + The following settings are deprecated since version 1.1. + URLs can be used as redirection source since 1.1, and then "MEDIA_ROOT" + and "MEDIA_URL" became too confuse. + + NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT: + Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR``. + + NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL: + Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. + + """ + def __init__(self): + """Use Django settings as configuration.""" + if settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is None: + raise ImproperlyConfigured( + 'settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is ' + 'required by %s middleware' % self.__class__.__name__) + super(SingleXAccelRedirectMiddleware, self).__init__( + source_dir=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR, + source_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL, + destination_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL, + expires=settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES, + with_buffering=settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING, + limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE) diff --git a/django_downloadview/nginx/response.py b/django_downloadview/nginx/response.py new file mode 100644 index 0000000..7fdadae --- /dev/null +++ b/django_downloadview/nginx/response.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Nginx's specific responses.""" +from datetime import timedelta + +from django.utils.timezone import now + +from django_downloadview.response import ProxiedDownloadResponse +from django_downloadview.utils import content_type_to_charset, url_basename + + +class XAccelRedirectResponse(ProxiedDownloadResponse): + "Http response that delegates serving file to Nginx via X-Accel headers." + def __init__(self, redirect_url, content_type, basename=None, expires=None, + with_buffering=None, limit_rate=None, attachment=True): + """Return a HttpResponse with headers for Nginx X-Accel-Redirect.""" + super(XAccelRedirectResponse, self).__init__(content_type=content_type) + if attachment: + self.basename = basename or url_basename(redirect_url, + content_type) + self['Content-Disposition'] = 'attachment; filename={name}'.format( + name=self.basename) + self['X-Accel-Redirect'] = redirect_url + self['X-Accel-Charset'] = content_type_to_charset(content_type) + if with_buffering is not None: + self['X-Accel-Buffering'] = with_buffering and 'yes' or 'no' + if expires: + expire_seconds = timedelta(expires - now()).seconds + self['X-Accel-Expires'] = expire_seconds + elif expires is not None: # We explicitely want it off. + self['X-Accel-Expires'] = 'off' + if limit_rate is not None: + self['X-Accel-Limit-Rate'] = (limit_rate + and '%d' % limit_rate + or 'off') diff --git a/django_downloadview/nginx/settings.py b/django_downloadview/nginx/settings.py new file mode 100644 index 0000000..9851c84 --- /dev/null +++ b/django_downloadview/nginx/settings.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +"""Django settings around Nginx X-Accel. + +.. warning:: + + These settings are deprecated since version 1.3. You can now provide custom + configuration via `DOWNLOADVIEW_MIDDLEWARES` setting. See :doc:`/settings` + for details. + +""" +import warnings + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + + +# In version 1.3, former XAccelRedirectMiddleware has been renamed to +# SingleXAccelRedirectMiddleware. So tell the users. +middleware = 'django_downloadview.nginx.XAccelRedirectMiddleware' +if middleware in settings.MIDDLEWARE_CLASSES: + raise ImproperlyConfigured( + '{middleware} middleware has been renamed as of django-downloadview ' + 'version 1.3. You may use ' + '"django_downloadview.nginx.SingleXAccelRedirectMiddleware" instead, ' + 'or upgrade to "django_downloadview.DownloadDispatcherMiddleware". ') + + +#: Default value for X-Accel-Buffering header. +#: Also default value for +#: ``settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING``. +#: +#: See http://wiki.nginx.org/X-accel#X-Accel-Limit-Buffering +#: +#: Default value is None, which means "let Nginx choose", i.e. use Nginx +#: defaults or specific configuration. +#: +#: If set to ``False``, Nginx buffering is disabled. +#: If set to ``True``, Nginx buffering is enabled. +DEFAULT_WITH_BUFFERING = None +setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING' +if hasattr(settings, setting_name): + warnings.warn('settings.{deprecated} is deprecated. You should combine ' + '"django_downloadview.DownloadDispatcherMiddleware" with ' + 'with DOWNLOADVIEW_MIDDLEWARES instead.', + DeprecationWarning) +if not hasattr(settings, setting_name): + setattr(settings, setting_name, DEFAULT_WITH_BUFFERING) + + +#: Default value for X-Accel-Limit-Rate header. +#: Also default value for ``settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE``. +#: +#: See http://wiki.nginx.org/X-accel#X-Accel-Limit-Rate +#: +#: Default value is None, which means "let Nginx choose", i.e. use Nginx +#: defaults or specific configuration. +#: +#: If set to ``False``, Nginx limit rate is disabled. +#: Else, it indicates the limit rate in bytes. +DEFAULT_LIMIT_RATE = None +setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE' +if hasattr(settings, setting_name): + warnings.warn('settings.{deprecated} is deprecated. You should combine ' + '"django_downloadview.DownloadDispatcherMiddleware" with ' + 'with DOWNLOADVIEW_MIDDLEWARES instead.', + DeprecationWarning) +if not hasattr(settings, setting_name): + setattr(settings, setting_name, DEFAULT_LIMIT_RATE) + + +#: Default value for X-Accel-Limit-Expires header. +#: Also default value for ``settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES``. +#: +#: See http://wiki.nginx.org/X-accel#X-Accel-Limit-Expires +#: +#: Default value is None, which means "let Nginx choose", i.e. use Nginx +#: defaults or specific configuration. +#: +#: If set to ``False``, Nginx buffering is disabled. +#: Else, it indicates the expiration delay, in seconds. +DEFAULT_EXPIRES = None +setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES' +if hasattr(settings, setting_name): + warnings.warn('settings.{deprecated} is deprecated. You should combine ' + '"django_downloadview.DownloadDispatcherMiddleware" with ' + 'with DOWNLOADVIEW_MIDDLEWARES instead.', + DeprecationWarning) +if not hasattr(settings, setting_name): + setattr(settings, setting_name, DEFAULT_EXPIRES) + + +#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR. +DEFAULT_SOURCE_DIR = settings.MEDIA_ROOT +setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT' +if hasattr(settings, setting_name): + warnings.warn('settings.{deprecated} is deprecated. You should combine ' + '"django_downloadview.DownloadDispatcherMiddleware" with ' + 'with DOWNLOADVIEW_MIDDLEWARES instead.', + DeprecationWarning) + DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT +setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR' +if hasattr(settings, setting_name): + warnings.warn('settings.{deprecated} is deprecated. You should combine ' + '"django_downloadview.DownloadDispatcherMiddleware" with ' + 'with DOWNLOADVIEW_MIDDLEWARES instead.', + DeprecationWarning) +if not hasattr(settings, setting_name): + setattr(settings, setting_name, DEFAULT_SOURCE_DIR) + + +#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL. +DEFAULT_SOURCE_URL = settings.MEDIA_URL +setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL' +if hasattr(settings, setting_name): + warnings.warn('settings.{deprecated} is deprecated. You should combine ' + '"django_downloadview.DownloadDispatcherMiddleware" with ' + 'with DOWNLOADVIEW_MIDDLEWARES instead.', + DeprecationWarning) +if not hasattr(settings, setting_name): + setattr(settings, setting_name, DEFAULT_SOURCE_URL) + + +#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL. +DEFAULT_DESTINATION_URL = None +setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL' +if hasattr(settings, setting_name): + warnings.warn('settings.{deprecated} is deprecated. You should combine ' + '"django_downloadview.DownloadDispatcherMiddleware" with ' + 'with DOWNLOADVIEW_MIDDLEWARES instead.', + DeprecationWarning) + DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL +setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL' +if hasattr(settings, setting_name): + warnings.warn('settings.{deprecated} is deprecated. You should combine ' + '"django_downloadview.DownloadDispatcherMiddleware" with ' + 'with DOWNLOADVIEW_MIDDLEWARES instead.', + DeprecationWarning) +if not hasattr(settings, setting_name): + setattr(settings, setting_name, DEFAULT_DESTINATION_URL) diff --git a/django_downloadview/nginx/tests.py b/django_downloadview/nginx/tests.py new file mode 100644 index 0000000..0cf7349 --- /dev/null +++ b/django_downloadview/nginx/tests.py @@ -0,0 +1,119 @@ +from django_downloadview.nginx.response import XAccelRedirectResponse + + +class XAccelRedirectValidator(object): + """Utility class to validate XAccelRedirectResponse instances. + + See also :py:func:`assert_x_accel_redirect` shortcut function. + + """ + def __call__(self, test_case, response, **assertions): + """Assert that ``response`` is a valid X-Accel-Redirect response. + + Optional ``assertions`` dictionary can be used to check additional + items: + + * ``basename``: the basename of the file in the response. + + * ``content_type``: the value of "Content-Type" header. + + * ``redirect_url``: the value of "X-Accel-Redirect" header. + + * ``charset``: the value of ``X-Accel-Charset`` header. + + * ``with_buffering``: the value of ``X-Accel-Buffering`` header. + If ``False``, then makes sure that the header disables buffering. + If ``None``, then makes sure that the header is not set. + + * ``expires``: the value of ``X-Accel-Expires`` header. + If ``False``, then makes sure that the header disables expiration. + If ``None``, then makes sure that the header is not set. + + * ``limit_rate``: the value of ``X-Accel-Limit-Rate`` header. + If ``False``, then makes sure that the header disables limit rate. + If ``None``, then makes sure that the header is not set. + + """ + self.assert_x_accel_redirect_response(test_case, response) + for key, value in assertions.iteritems(): + assert_func = getattr(self, 'assert_%s' % key) + assert_func(test_case, response, value) + + def assert_x_accel_redirect_response(self, test_case, response): + test_case.assertTrue(isinstance(response, XAccelRedirectResponse)) + + def assert_basename(self, test_case, response, value): + test_case.assertEqual(response.basename, value) + + def assert_content_type(self, test_case, response, value): + test_case.assertEqual(response['Content-Type'], value) + + def assert_redirect_url(self, test_case, response, value): + test_case.assertEqual(response['X-Accel-Redirect'], value) + + def assert_charset(self, test_case, response, value): + test_case.assertEqual(response['X-Accel-Charset'], value) + + def assert_with_buffering(self, test_case, response, value): + header = 'X-Accel-Buffering' + if value is None: + test_case.assertFalse(header in response) + elif value: + test_case.assertEqual(header, 'yes') + else: + test_case.assertEqual(header, 'no') + + def assert_expires(self, test_case, response, value): + header = 'X-Accel-Expires' + if value is None: + test_case.assertFalse(header in response) + elif not value: + test_case.assertEqual(header, 'off') + else: + test_case.assertEqual(header, value) + + def assert_limit_rate(self, test_case, response, value): + header = 'X-Accel-Limit-Rate' + if value is None: + test_case.assertFalse(header in response) + elif not value: + test_case.assertEqual(header, 'off') + else: + test_case.assertEqual(header, value) + + def assert_attachment(self, test_case, response, value): + header = 'Content-Disposition' + if value: + test_case.assertTrue(response[header].startswith('attachment')) + else: + test_case.assertFalse(header in response) + + +def assert_x_accel_redirect(test_case, response, **assertions): + """Make ``test_case`` assert that ``response`` is a XAccelRedirectResponse. + + Optional ``assertions`` dictionary can be used to check additional items: + + * ``basename``: the basename of the file in the response. + + * ``content_type``: the value of "Content-Type" header. + + * ``redirect_url``: the value of "X-Accel-Redirect" header. + + * ``charset``: the value of ``X-Accel-Charset`` header. + + * ``with_buffering``: the value of ``X-Accel-Buffering`` header. + If ``False``, then makes sure that the header disables buffering. + If ``None``, then makes sure that the header is not set. + + * ``expires``: the value of ``X-Accel-Expires`` header. + If ``False``, then makes sure that the header disables expiration. + If ``None``, then makes sure that the header is not set. + + * ``limit_rate``: the value of ``X-Accel-Limit-Rate`` header. + If ``False``, then makes sure that the header disables limit rate. + If ``None``, then makes sure that the header is not set. + + """ + validator = XAccelRedirectValidator() + return validator(test_case, response, **assertions) diff --git a/django_downloadview/response.py b/django_downloadview/response.py index 0f69509..2745b56 100644 --- a/django_downloadview/response.py +++ b/django_downloadview/response.py @@ -3,7 +3,7 @@ import os import mimetypes from django.conf import settings -from django.http import StreamingHttpResponse +from django.http import HttpResponse, StreamingHttpResponse class DownloadResponse(StreamingHttpResponse): @@ -12,6 +12,8 @@ class DownloadResponse(StreamingHttpResponse): ``content`` attribute is supposed to be a file object wrapper, which makes this response "lazy". + This is a specialization of Django's :py:class:`StreamingHttpResponse`. + """ def __init__(self, file_instance, attachment=True, basename=None, status=200, content_type=None): @@ -120,11 +122,10 @@ class DownloadResponse(StreamingHttpResponse): return settings.DEFAULT_CHARSET -def is_download_response(response): - """Return ``True`` if ``response`` is a download response. +class ProxiedDownloadResponse(HttpResponse): + """Base class for internal redirect download responses. - Current implementation returns True if ``response`` is an instance of - :py:class:`django_downloadview.response.DownloadResponse`. + This base class makes it possible to identify several types of specific + responses such as :py:class:`django_downloadview.XAccelRedirectResponse`. """ - return isinstance(response, DownloadResponse) diff --git a/django_downloadview/test.py b/django_downloadview/test.py index f4b72be..582512b 100644 --- a/django_downloadview/test.py +++ b/django_downloadview/test.py @@ -5,7 +5,7 @@ import tempfile from django.conf import settings from django.test.utils import override_settings -from django_downloadview.response import is_download_response +from django_downloadview.middlewares import is_download_response class temporary_media_root(override_settings): diff --git a/django_downloadview/utils.py b/django_downloadview/utils.py index daddb05..37f5bbf 100644 --- a/django_downloadview/utils.py +++ b/django_downloadview/utils.py @@ -1,4 +1,5 @@ -"""Utility functions.""" +# -*- coding: utf-8 -*- +"""Utility functions that may be implemented in external packages.""" import re @@ -16,3 +17,17 @@ def content_type_to_charset(content_type): match = re.search(charset_pattern, content_type) if match: return match.group('charset') + + +def url_basename(url, content_type): + """Return best-guess basename from URL and content-type. + + >>> from django_downloadview.utils import url_basename + + If URL contains extension, it is kept as-is. + + >>> url_basename(u'/path/to/somefile.rst', 'text/plain') + u'somefile.rst' + + """ + return url.split('/')[-1] diff --git a/docs/api/django_downloadview.nginx.txt b/docs/api/django_downloadview.nginx.txt new file mode 100644 index 0000000..f26fb4b --- /dev/null +++ b/docs/api/django_downloadview.nginx.txt @@ -0,0 +1,51 @@ +nginx Package +============= + +:mod:`nginx` Package +-------------------- + +.. automodule:: django_downloadview.nginx + :members: + :undoc-members: + :show-inheritance: + +:mod:`decorators` Module +------------------------ + +.. automodule:: django_downloadview.nginx.decorators + :members: + :undoc-members: + :show-inheritance: + +:mod:`middlewares` Module +------------------------- + +.. automodule:: django_downloadview.nginx.middlewares + :members: + :undoc-members: + :show-inheritance: + +:mod:`response` Module +---------------------- + +.. automodule:: django_downloadview.nginx.response + :members: + :undoc-members: + :show-inheritance: + +:mod:`settings` Module +---------------------- + +.. automodule:: django_downloadview.nginx.settings + :members: + :undoc-members: + :show-inheritance: + +:mod:`tests` Module +------------------- + +.. automodule:: django_downloadview.nginx.tests + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/api/django_downloadview.txt b/docs/api/django_downloadview.txt index e32220a..ce4986c 100644 --- a/docs/api/django_downloadview.txt +++ b/docs/api/django_downloadview.txt @@ -33,14 +33,6 @@ django_downloadview Package :undoc-members: :show-inheritance: -:mod:`nginx` Module -------------------- - -.. automodule:: django_downloadview.nginx - :members: - :undoc-members: - :show-inheritance: - :mod:`response` Module ---------------------- @@ -73,3 +65,10 @@ django_downloadview Package :undoc-members: :show-inheritance: +Subpackages +----------- + +.. toctree:: + + django_downloadview.nginx + diff --git a/docs/index.txt b/docs/index.txt index 31ba8e5..fd2a3d5 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -11,6 +11,7 @@ Contents demo install + settings views optimizations/index testing diff --git a/docs/optimizations/index.txt b/docs/optimizations/index.txt index 5c283db..34dc9bf 100644 --- a/docs/optimizations/index.txt +++ b/docs/optimizations/index.txt @@ -26,11 +26,11 @@ How does it work? The feature is inspired by `Django's TemplateResponse`_: the download views return some :py:class:`django_downloadview.response.DownloadResponse` instance. -Such a response doesn't contain file data. +Such a response does not contain file data. -By default, at the end of Django's request/response handling, Django is to -iterate over the ``content`` attribute of the response. In a -``DownloadResponse``, this ``content`` attribute is a file wrapper. +By default, at the end of Django's request/response handling, Django iterates +over the ``content`` attribute of the response. In a `DownloadResponse``, +this ``content`` attribute is a file wrapper. It means that decorators and middlewares are given an opportunity to capture the ``DownloadResponse`` before the content of the file is loaded into memory @@ -39,6 +39,33 @@ replaces ``DownloadResponse`` intance by some :py:class:`django_downloadview.nginx.XAccelRedirectResponse`. +********* +Configure +********* + +Add `django_downloadview.DownloadDispatcherMiddleware` to `MIDDLEWARE_CLASSES` +in your Django settings. + +Then register as many download middlewares as you wish in +`DOWNLOADVIEW_MIDDLEWARES`. + +.. code:: python + + DOWNLOADVIEW_MIDDLEWARES = ( + ('default', + 'django_downloadview.nginx.XAccelRedirectMiddleware', + {'source_dir': MEDIA_ROOT, 'destination_url': '/proxied-download'}), + ) + +The first item is an identifier. + +The second item is the import path of some download middleware factory +(typically a class). + +The third item is a dictionary of keyword arguments passed to the middleware +factory. + + .. rubric:: References .. target-notes:: diff --git a/docs/settings.txt b/docs/settings.txt new file mode 100644 index 0000000..05a0530 --- /dev/null +++ b/docs/settings.txt @@ -0,0 +1,39 @@ +######## +Settings +######## + +Here is the list of settings used by `django-downloadview`. + + +****************** +MIDDLEWARE_CLASSES +****************** + +Add `django_downloadview.DownloadDispatcherMiddleware` to `MIDDLEWARE_CLASSES`. +This is a response middleware. Move it after middlewares that compute the +response content such as gzip middleware. + + +************************ +DOWNLOADVIEW_MIDDLEWARES +************************ + +:default: [] + +A list of `(id, path, options)` where: + +* `id` is an identifier +* `path` is the import path of some download middleware factory (typically a + class). +* `options` is a dictionary of keyword arguments passed to the middleware + factory. + +Example: + +.. code:: python + + DOWNLOADVIEW_MIDDLEWARES = ( + ('default', + 'django_downloadview.nginx.XAccelMiddleware', + {'source_dir': MEDIA_ROOT, 'destination_url': '/proxied-download'}), + ) From fbfd2ecee41afb7427d1797bc1708380158057d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 14:26:46 +0200 Subject: [PATCH 03/25] Using sphinx.ext.intersphinx to reference Django and Python documentations. --- docs/conf.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 3bef259..bad7e86 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,8 +10,7 @@ # # All configuration values have a default; values that are commented out # serve to show the default. - -import sys, os +import os # Minimal Django settings. Required to use sphinx.ext.autodoc, because @@ -41,8 +40,11 @@ version_filename = os.path.join(project_dir, 'VERSION') # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.autosummary', - 'sphinx.ext.doctest', 'sphinx.ext.coverage'] +extensions = ['sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.doctest', + 'sphinx.ext.coverage', + 'sphinx.ext.intersphinx'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -189,6 +191,14 @@ html_sidebars = { htmlhelp_basename = 'django-downloadviewdoc' +# -- Options for sphinx.ext.intersphinx --------------------------------------- + +intersphinx_mapping = { + 'python': ('http://docs.python.org/2.7', None), + 'django': ('http://docs.djangoproject.com/en/1.5/', + 'http://docs.djangoproject.com/en/1.5/_objects/'), +} + # -- Options for LaTeX output -------------------------------------------------- latex_elements = { From a93561601aec3c0fbef303c39501f1a088882938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 14:27:38 +0200 Subject: [PATCH 04/25] Changed encoding declaration style in setup.py. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0eda40e..d92042c 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# coding=utf-8 +# -*- coding: utf-8 -*- """Python packaging.""" import os from setuptools import setup From f623728bb9dea27d6193203c35cb85323bd787d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 14:29:12 +0200 Subject: [PATCH 05/25] Enabled collective.recipe.omelette in DEV environment. --- etc/buildout.cfg | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/etc/buildout.cfg b/etc/buildout.cfg index 2d04b51..52d19e6 100644 --- a/etc/buildout.cfg +++ b/etc/buildout.cfg @@ -27,6 +27,7 @@ parts = directories releaser activate + omelette [django-downloadview] recipe = z3c.recipe.scripts @@ -57,9 +58,15 @@ eggs = zest.releaser [activate] recipe = evg.recipe.activate +[omelette] +recipe = collective.recipe.omelette +eggs = ${django-downloadview:eggs} +location = ${buildout:directory}/lib/omelette + [versions] bpython = 0.10.1 buildout-versions = 1.7 +collective.recipe.omelette = 0.16 coverage = 3.6 distribute = 0.6.34 Django = 1.5 From 81e9607b82fcaf4befab66d4a25ff79b19e33a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 14:30:41 +0200 Subject: [PATCH 06/25] Reviewed README. --- README | 38 ++++++++++++-------------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/README b/README index f0d0d76..cbc1108 100644 --- a/README +++ b/README @@ -2,22 +2,26 @@ django-downloadview ################### -`django-downloadview` makes it easy to serve files with Django. +``django-downloadview`` makes it easy to serve files with Django: -It provides generic views to serve files from models, storages, local -filesystem, arbitrary URL... and even generated files. +* you manage files with Django (permissions, search, generation, ...); -For increased performances, it can delegate the actual streaming to a reverse -proxy, via mechanisms such as Nginx's X-Accel. +* files are stored somewhere or generated somehow (local filesystem, remote + storage, memory...); + +* ``django-downloadview`` helps you stream the files with very little code; + +* ``django-downloadview`` helps you improve performances with reverse proxies, + via mechanisms such as Nginx's X-Accel. ******* Example ******* -In some ``urls.py``, serve files managed in a model: +Let's serve a file stored in a FileField of some model: -.. code-block:: python +.. code:: python from django.conf.urls import url, url_patterns from django_downloadview import ObjectDownloadView @@ -30,25 +34,6 @@ In some ``urls.py``, serve files managed in a model: url('^download/(?P[A-Za-z0-9_-]+)/$', download, name='download'), ) -More examples in the "demo" documentation! - - -***** -Views -***** - -Several views are provided to cover frequent use cases: - -* ``ObjectDownloadView`` when you have a model with a file field. -* ``StorageDownloadView`` when you manage files in a storage. -* ``PathDownloadView`` when you have an absolute filename on local filesystem. -* ``HTTPDownloadView`` when you have an URL (the resource is proxied). -* ``VirtualDownloadView`` when you the file is generated on the fly. - -See "views" documentation for details. - -See also "optimizations" documentation to get increased performances. - ********** Ressources @@ -59,3 +44,4 @@ Ressources * Code repository: https://github.com/benoitbryon/django-downloadview * Bugtracker: https://github.com/benoitbryon/django-downloadview/issues * Continuous integration: https://travis-ci.org/benoitbryon/django-downloadview +* Roadmap: https://github.com/benoitbryon/django-downloadview/issues/milestones From 80392705b7a3182453734cf03bc60966e337f691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 14:35:06 +0200 Subject: [PATCH 07/25] Refs #25, refs #39 - Simplified INSTALL, reviewed docs/settings.txt. --- INSTALL | 24 ++++++----------------- docs/settings.txt | 50 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/INSTALL b/INSTALL index d8b4f9c..961f35c 100644 --- a/INSTALL +++ b/INSTALL @@ -1,14 +1,10 @@ -############ -Installation -############ - -`django-downloadview` project is open-source, published under BSD license. -See :doc:`/about/license` for details. +####### +Install +####### .. note:: - If you want to install a development environment, please see :doc:`/dev` - documentation. + If you want to install a development environment, please see :doc:`/dev`. System requirements: @@ -24,16 +20,7 @@ pip: .. note:: Since version 1.1, django-downloadview requires Django>=1.5, which provides - StreamingHttpResponse. - -There is no need to register this application in your Django's -``INSTALLED_APPS`` setting. - -Next, you'll have to setup some download view(s). See :doc:`demo project -` for examples, and :doc:`API documentation `. - -Optionally, you may setup additional :doc:`server optimizations -`. + :py:class:`~django.http.StreamingHttpResponse`. .. rubric:: Notes & references @@ -42,3 +29,4 @@ Optionally, you may setup additional :doc:`server optimizations * :doc:`/settings` * :doc:`/about/changelog` + * :doc:`/about/license` diff --git a/docs/settings.txt b/docs/settings.txt index 05a0530..7a2d090 100644 --- a/docs/settings.txt +++ b/docs/settings.txt @@ -1,18 +1,40 @@ -######## -Settings -######## +######### +Configure +######### Here is the list of settings used by `django-downloadview`. +************** +INSTALLED_APPS +************** + +There is no need to register this application in your Django's +``INSTALLED_APPS`` setting. + + ****************** MIDDLEWARE_CLASSES ****************** -Add `django_downloadview.DownloadDispatcherMiddleware` to `MIDDLEWARE_CLASSES`. -This is a response middleware. Move it after middlewares that compute the +If you plan to setup reverse-proxy optimizations, add +``django_downloadview.DownloadDispatcherMiddleware`` to ``MIDDLEWARE_CLASSES``. +It is a response middleware. Move it after middlewares that compute the response content such as gzip middleware. +Example: + +.. code:: python + + MIDDLEWARE_CLASSES = [ + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django_downloadview.DownloadDispatcherMiddleware', + ] + ************************ DOWNLOADVIEW_MIDDLEWARES @@ -20,12 +42,19 @@ DOWNLOADVIEW_MIDDLEWARES :default: [] -A list of `(id, path, options)` where: +If you plan to setup reverse-proxy :doc:`optimizations `, +setup ``DOWNLOADVIEW_MIDDLEWARES`` value. This setting is used by +:py:class:`~django_downloadview.middlewares.DownloadDispatcherMiddleware`. +It is the list of handlers that will be given the opportunity to capture +download responses and convert them to internal redirects for use with +reverse-proxies. -* `id` is an identifier -* `path` is the import path of some download middleware factory (typically a +The list expects items ``(id, path, options)`` such as: + +* ``id`` is an identifier +* ``path`` is the import path of some download middleware factory (typically a class). -* `options` is a dictionary of keyword arguments passed to the middleware +* ``options`` is a dictionary of keyword arguments passed to the middleware factory. Example: @@ -37,3 +66,6 @@ Example: 'django_downloadview.nginx.XAccelMiddleware', {'source_dir': MEDIA_ROOT, 'destination_url': '/proxied-download'}), ) + +See :doc:`/optimizations/index` for details about middlewares and their +options. From 5f0e40e23faaa7c95502697c14abb1bffea96b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 14:36:46 +0200 Subject: [PATCH 08/25] Refs #39 - Removed auto-generated API documentation. --- Makefile | 9 +--- docs/api/django_downloadview.nginx.txt | 51 ------------------ docs/api/django_downloadview.txt | 74 -------------------------- docs/api/index.txt | 9 ---- docs/api/modules.txt | 7 --- 5 files changed, 1 insertion(+), 149 deletions(-) delete mode 100644 docs/api/django_downloadview.nginx.txt delete mode 100644 docs/api/django_downloadview.txt delete mode 100644 docs/api/index.txt delete mode 100644 docs/api/modules.txt diff --git a/Makefile b/Makefile index 2398327..92905a0 100644 --- a/Makefile +++ b/Makefile @@ -64,18 +64,11 @@ test-documentation: $(NOSE) -c $(ROOT_DIR)/etc/nose.cfg sphinxcontrib.testbuild.tests -apidoc: - cp docs/api/index.txt docs/api-backup.txt - rm -rf docs/api/* - mv docs/api-backup.txt docs/api/index.txt - $(BIN_DIR)/sphinx-apidoc --suffix txt --output-dir $(ROOT_DIR)/docs/api django_downloadview - - sphinx: make --directory=docs clean html doctest -documentation: apidoc sphinx +documentation: sphinx demo: develop diff --git a/docs/api/django_downloadview.nginx.txt b/docs/api/django_downloadview.nginx.txt deleted file mode 100644 index f26fb4b..0000000 --- a/docs/api/django_downloadview.nginx.txt +++ /dev/null @@ -1,51 +0,0 @@ -nginx Package -============= - -:mod:`nginx` Package --------------------- - -.. automodule:: django_downloadview.nginx - :members: - :undoc-members: - :show-inheritance: - -:mod:`decorators` Module ------------------------- - -.. automodule:: django_downloadview.nginx.decorators - :members: - :undoc-members: - :show-inheritance: - -:mod:`middlewares` Module -------------------------- - -.. automodule:: django_downloadview.nginx.middlewares - :members: - :undoc-members: - :show-inheritance: - -:mod:`response` Module ----------------------- - -.. automodule:: django_downloadview.nginx.response - :members: - :undoc-members: - :show-inheritance: - -:mod:`settings` Module ----------------------- - -.. automodule:: django_downloadview.nginx.settings - :members: - :undoc-members: - :show-inheritance: - -:mod:`tests` Module -------------------- - -.. automodule:: django_downloadview.nginx.tests - :members: - :undoc-members: - :show-inheritance: - diff --git a/docs/api/django_downloadview.txt b/docs/api/django_downloadview.txt deleted file mode 100644 index ce4986c..0000000 --- a/docs/api/django_downloadview.txt +++ /dev/null @@ -1,74 +0,0 @@ -django_downloadview Package -=========================== - -:mod:`django_downloadview` Package ----------------------------------- - -.. automodule:: django_downloadview.__init__ - :members: - :undoc-members: - :show-inheritance: - -:mod:`decorators` Module ------------------------- - -.. automodule:: django_downloadview.decorators - :members: - :undoc-members: - :show-inheritance: - -:mod:`files` Module -------------------- - -.. automodule:: django_downloadview.files - :members: - :undoc-members: - :show-inheritance: - -:mod:`middlewares` Module -------------------------- - -.. automodule:: django_downloadview.middlewares - :members: - :undoc-members: - :show-inheritance: - -:mod:`response` Module ----------------------- - -.. automodule:: django_downloadview.response - :members: - :undoc-members: - :show-inheritance: - -:mod:`test` Module ------------------- - -.. automodule:: django_downloadview.test - :members: - :undoc-members: - :show-inheritance: - -:mod:`utils` Module -------------------- - -.. automodule:: django_downloadview.utils - :members: - :undoc-members: - :show-inheritance: - -:mod:`views` Module -------------------- - -.. automodule:: django_downloadview.views - :members: - :undoc-members: - :show-inheritance: - -Subpackages ------------ - -.. toctree:: - - django_downloadview.nginx - diff --git a/docs/api/index.txt b/docs/api/index.txt deleted file mode 100644 index e030b47..0000000 --- a/docs/api/index.txt +++ /dev/null @@ -1,9 +0,0 @@ -### -API -### - -Here is API documentation, generated from code. - -.. toctree:: - - modules diff --git a/docs/api/modules.txt b/docs/api/modules.txt deleted file mode 100644 index bb6a157..0000000 --- a/docs/api/modules.txt +++ /dev/null @@ -1,7 +0,0 @@ -django_downloadview -=================== - -.. toctree:: - :maxdepth: 4 - - django_downloadview From f34deba5037fc2a02515ed2767c5081be382c47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 14:54:57 +0200 Subject: [PATCH 09/25] Moved API imports from django_downloadview's __init__.py to api.py. --- django_downloadview/__init__.py | 11 ++--------- django_downloadview/api.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 django_downloadview/api.py diff --git a/django_downloadview/__init__.py b/django_downloadview/__init__.py index f74eee2..11c6eed 100644 --- a/django_downloadview/__init__.py +++ b/django_downloadview/__init__.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """django-downloadview provides generic download views for Django.""" import pkg_resources @@ -8,12 +9,4 @@ __version__ = pkg_resources.get_distribution(__package__.replace('-', '_')) \ # API shortcuts. -from django_downloadview.response import DownloadResponse # NoQA -from django_downloadview.middlewares import ( # NoQA - BaseDownloadMiddleware, - DownloadDispatcherMiddleware) -from django_downloadview.nginx import XAccelRedirectMiddleware # NoQA -from django_downloadview.views import (PathDownloadView, # NoQA - ObjectDownloadView, # NoQA - StorageDownloadView, # NoQA - VirtualDownloadView) # NoQA +from django_downloadview.api import * # NoQA diff --git a/django_downloadview/api.py b/django_downloadview/api.py new file mode 100644 index 0000000..7178c1e --- /dev/null +++ b/django_downloadview/api.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""Declaration of API shortcuts.""" +from django_downloadview.io import StringIteratorIO # NoQA +from django_downloadview.files import (StorageFile, # NoQA + VirtualFile, + HTTPFile) +from django_downloadview.response import (DownloadResponse, # NoQA + ProxiedDownloadResponse) +from django_downloadview.middlewares import (BaseDownloadMiddleware, # NoQA + DownloadDispatcherMiddleware) +from django_downloadview.nginx import XAccelRedirectMiddleware # NoQA +from django_downloadview.views import (PathDownloadView, # NoQA + ObjectDownloadView, + StorageDownloadView, + VirtualDownloadView) +from django_downloadview.sendfile import sendfile # NoQA From 7c3b3a8de2d63823662e605e529c9465994917c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 14:57:54 +0200 Subject: [PATCH 10/25] Splitted django_downloadview.views in several smaller modules. --- django_downloadview/views.py | 286 -------------------------- django_downloadview/views/__init__.py | 12 ++ django_downloadview/views/base.py | 106 ++++++++++ django_downloadview/views/http.py | 34 +++ django_downloadview/views/object.py | 78 +++++++ django_downloadview/views/path.py | 33 +++ django_downloadview/views/storage.py | 29 +++ django_downloadview/views/virtual.py | 33 +++ 8 files changed, 325 insertions(+), 286 deletions(-) delete mode 100644 django_downloadview/views.py create mode 100644 django_downloadview/views/__init__.py create mode 100644 django_downloadview/views/base.py create mode 100644 django_downloadview/views/http.py create mode 100644 django_downloadview/views/object.py create mode 100644 django_downloadview/views/path.py create mode 100644 django_downloadview/views/storage.py create mode 100644 django_downloadview/views/virtual.py diff --git a/django_downloadview/views.py b/django_downloadview/views.py deleted file mode 100644 index 8eee5d9..0000000 --- a/django_downloadview/views.py +++ /dev/null @@ -1,286 +0,0 @@ -# coding=utf-8 -"""Views.""" -from django.core.files import File -from django.core.files.storage import DefaultStorage -from django.http import HttpResponseNotModified -from django.views.generic.base import View -from django.views.generic.detail import BaseDetailView -from django.views.static import was_modified_since - -import requests - -from django_downloadview import files -from django_downloadview.response import DownloadResponse - - -class DownloadMixin(object): - """Placeholders and base implementation to create file download views. - - .. note:: - - This class does not inherit from - :py:class:`django.views.generic.base.View`. - - The :py:meth:`get_file` method is a placeholder subclasses must implement. - Base implementation raises ``NotImplementedError``. - - Other methods provide a base implementation that use the file wrapper - returned by :py:meth:`get_file`. - - """ - #: Response class, to be used in :py:meth:`render_to_response`. - response_class = DownloadResponse - - #: Whether to return the response as attachment or not. - attachment = True - - #: Client-side filename, if only file is returned as attachment. - basename = None - - def get_file(self): - """Return a file wrapper instance.""" - raise NotImplementedError() - - def get_basename(self): - return self.basename - - def was_modified_since(self, file_instance, since): - """Return True if ``file_instance`` was modified after ``since``. - - Uses file wrapper's ``was_modified_since`` if available, with value of - ``since`` as positional argument. - - Else, fallbacks to default implementation, which uses - :py:func:`django.views.static.was_modified_since`. - - Django's ``was_modified_since`` function needs a datetime and a size. - It is passed ``modified_time`` and ``size`` attributes from file - wrapper. If file wrapper does not support these attributes - (``AttributeError`` or ``NotImplementedError`` is raised), then - the file is considered as modified and ``True`` is returned. - - """ - try: - return file_instance.was_modified_since(since) - except (AttributeError, NotImplementedError): - try: - modification_time = file_instance.modified_time - size = file_instance.size - except (AttributeError, NotImplementedError): - return True - else: - return was_modified_since(since, modification_time, size) - - def not_modified_response(self, *args, **kwargs): - """Return :py:class:`django.http.HttpResponseNotModified` instance.""" - content_type = self.file_instance.content_type - return HttpResponseNotModified(content_type=content_type) - - def download_response(self, *args, **kwargs): - """Return :py:class:`DownloadResponse` instance.""" - response_kwargs = {'file_instance': self.file_instance, - 'attachment': self.attachment, - 'basename': self.get_basename()} - response_kwargs.update(kwargs) - response = self.response_class(**response_kwargs) - return response - - def render_to_response(self, *args, **kwargs): - """Return a download response. - - Respects the "HTTP_IF_MODIFIED_SINCE" header if any. In that case, uses - :py:meth:`was_modified_since` and :py:meth:`not_modified_response`. - - Else, uses :py:meth:`download_response` to return a download response. - - """ - self.file_instance = self.get_file() - # Respect the If-Modified-Since header. - since = self.request.META.get('HTTP_IF_MODIFIED_SINCE', None) - if since is not None: - if not self.was_modified_since(self.file_instance, since): - return self.not_modified_response(*args, **kwargs) - # Return download response. - return self.download_response(*args, **kwargs) - - -class BaseDownloadView(DownloadMixin, View): - def get(self, request, *args, **kwargs): - """Handle GET requests: stream a file.""" - return self.render_to_response() - - -class PathDownloadView(BaseDownloadView): - """Serve a file using filename.""" - #: Server-side name (including path) of the file to serve. - #: - #: Filename is supposed to be an absolute filename of a file located on the - #: local filesystem. - path = None - - #: Name of the URL argument that contains path. - path_url_kwarg = 'path' - - def get_path(self): - """Return actual path of the file to serve. - - Default implementation simply returns view's :py:attr:`path`. - - Override this method if you want custom implementation. - As an example, :py:attr:`path` could be relative and your custom - :py:meth:`get_path` implementation makes it absolute. - - """ - return self.kwargs.get(self.path_url_kwarg, self.path) - - def get_file(self): - """Use path to return wrapper around file to serve.""" - return File(open(self.get_path())) - - -class StorageDownloadView(PathDownloadView): - """Serve a file using storage and filename.""" - #: Storage the file to serve belongs to. - storage = DefaultStorage() - - #: Path to the file to serve relative to storage. - path = None # Override docstring. - - def get_path(self): - """Return path of the file to serve, relative to storage. - - Default implementation simply returns view's :py:attr:`path`. - - Override this method if you want custom implementation. - - """ - return super(StorageDownloadView, self).get_path() - - def get_file(self): - """Use path and storage to return wrapper around file to serve.""" - return files.StorageFile(self.storage, self.get_path()) - - -class VirtualDownloadView(BaseDownloadView): - """Serve not-on-disk or generated-on-the-fly file. - - Use this class to serve :py:class:`StringIO` files. - - Override the :py:meth:`get_file` method to customize file wrapper. - - """ - def get_file(self): - """Return wrapper.""" - raise NotImplementedError() - - def was_modified_since(self, file_instance, since): - """Delegate to file wrapper's was_modified_since, or return True. - - This is the implementation of an edge case: when files are generated - on the fly, we cannot guess whether they have been modified or not. - If the file wrapper implements ``was_modified_since()`` method, then we - trust it. Otherwise it is safer to suppose that the file has been - modified. - - This behaviour prevents file size to be computed on the Django side. - Because computing file size means iterating over all the file contents, - and we want to avoid that whenever possible. As an example, it could - reduce all the benefits of working with dynamic file generators... - which is a major feature of virtual files. - - """ - try: - return file_instance.was_modified_since(since) - except (AttributeError, NotImplementedError): - return True - - -class HTTPDownloadView(BaseDownloadView): - """Proxy files that live on remote servers.""" - #: URL to download (the one we are proxying). - url = u'' - - #: Additional keyword arguments for request handler. - request_kwargs = {} - - def get_request_factory(self): - """Return request factory to perform actual HTTP request.""" - return requests.get - - def get_request_kwargs(self): - """Return keyword arguments for use with request factory.""" - return self.request_kwargs - - def get_url(self): - """Return remote file URL (the one we are proxying).""" - return self.url - - def get_file(self): - """Return wrapper which has an ``url`` attribute.""" - return files.HTTPFile(request_factory=self.get_request_factory(), - name=self.get_basename(), - url=self.get_url(), - **self.get_request_kwargs()) - - -class ObjectDownloadView(DownloadMixin, BaseDetailView): - """Download view for models which contain a FileField. - - This class extends BaseDetailView, so you can use its arguments to target - the instance to operate on: slug, slug_kwarg, model, queryset... - See Django's DetailView reference for details. - - In addition to BaseDetailView arguments, you can set arguments related to - the file to be downloaded. - - The main one is ``file_field``. - - The other arguments are provided for convenience, in case your model holds - some (deserialized) metadata about the file, such as its basename, its - modification time, its MIME type... These fields may be particularly handy - if your file storage is not the local filesystem. - - """ - #: Name of the model's attribute which contains the file to be streamed. - #: Typically the name of a FileField. - file_field = 'file' - - #: Optional name of the model's attribute which contains the basename. - basename_field = None - - #: Optional name of the model's attribute which contains the encoding. - encoding_field = None - - #: Optional name of the model's attribute which contains the MIME type. - mime_type_field = None - - #: Optional name of the model's attribute which contains the charset. - charset_field = None - - #: Optional name of the model's attribute which contains the modification - # time. - modification_time_field = None - - #: Optional name of the model's attribute which contains the size. - size_field = None - - def get_file(self): - """Return FieldFile instance.""" - file_instance = getattr(self.object, self.file_field) - for field in ('encoding', 'mime_type', 'charset', 'modification_time', - 'size'): - model_field = getattr(self, '%s_field' % field, False) - if model_field: - value = getattr(self.object, model_field) - setattr(file_instance, field, value) - return file_instance - - def get_basename(self): - """Return client-side filename.""" - basename = super(ObjectDownloadView, self).get_basename() - if basename is None: - field = 'basename' - model_field = getattr(self, '%s_field' % field, False) - if model_field: - basename = getattr(self.object, model_field) - return basename diff --git a/django_downloadview/views/__init__.py b/django_downloadview/views/__init__.py new file mode 100644 index 0000000..608dd94 --- /dev/null +++ b/django_downloadview/views/__init__.py @@ -0,0 +1,12 @@ +# coding=utf-8 +"""Views.""" +# -*- coding: utf-8 -*- +"""Views to stream files.""" +# API shortcuts. +from django_downloadview.views.base import (DownloadMixin, # NoQA + BaseDownloadView) +from django_downloadview.views.path import PathDownloadView # NoQA +from django_downloadview.views.storage import StorageDownloadView # NoQA +from django_downloadview.views.object import ObjectDownloadView # NoQA +from django_downloadview.views.http import HTTPDownloadView # NoQA +from django_downloadview.views.virtual import VirtualDownloadView # NoQA diff --git a/django_downloadview/views/base.py b/django_downloadview/views/base.py new file mode 100644 index 0000000..00bb261 --- /dev/null +++ b/django_downloadview/views/base.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""Base material for download views: :class:`DownloadMixin` and +:class:`BaseDownloadView`""" +from django.http import HttpResponseNotModified +from django.views.generic.base import View +from django.views.static import was_modified_since + +from django_downloadview.response import DownloadResponse + + +class DownloadMixin(object): + """Placeholders and base implementation to create file download views. + + .. note:: + + This class does not inherit from + :py:class:`django.views.generic.base.View`. + + The :py:meth:`get_file` method is a placeholder subclasses must implement. + Base implementation raises ``NotImplementedError``. + + Other methods provide a base implementation that use the file wrapper + returned by :py:meth:`get_file`. + + """ + #: Response class, to be used in :py:meth:`render_to_response`. + response_class = DownloadResponse + + #: Whether to return the response as attachment or not. + attachment = True + + #: Client-side filename, if only file is returned as attachment. + basename = None + + def get_file(self): + """Return a file wrapper instance.""" + raise NotImplementedError() + + def get_basename(self): + return self.basename + + def was_modified_since(self, file_instance, since): + """Return True if ``file_instance`` was modified after ``since``. + + Uses file wrapper's ``was_modified_since`` if available, with value of + ``since`` as positional argument. + + Else, fallbacks to default implementation, which uses + :py:func:`django.views.static.was_modified_since`. + + Django's ``was_modified_since`` function needs a datetime and a size. + It is passed ``modified_time`` and ``size`` attributes from file + wrapper. If file wrapper does not support these attributes + (``AttributeError`` or ``NotImplementedError`` is raised), then + the file is considered as modified and ``True`` is returned. + + """ + try: + return file_instance.was_modified_since(since) + except (AttributeError, NotImplementedError): + try: + modification_time = file_instance.modified_time + size = file_instance.size + except (AttributeError, NotImplementedError): + return True + else: + return was_modified_since(since, modification_time, size) + + def not_modified_response(self, *args, **kwargs): + """Return :py:class:`django.http.HttpResponseNotModified` instance.""" + content_type = self.file_instance.content_type + return HttpResponseNotModified(content_type=content_type) + + def download_response(self, *args, **kwargs): + """Return :py:class:`DownloadResponse` instance.""" + response_kwargs = {'file_instance': self.file_instance, + 'attachment': self.attachment, + 'basename': self.get_basename()} + response_kwargs.update(kwargs) + response = self.response_class(**response_kwargs) + return response + + def render_to_response(self, *args, **kwargs): + """Return a download response. + + Respects the "HTTP_IF_MODIFIED_SINCE" header if any. In that case, uses + :py:meth:`was_modified_since` and :py:meth:`not_modified_response`. + + Else, uses :py:meth:`download_response` to return a download response. + + """ + self.file_instance = self.get_file() + # Respect the If-Modified-Since header. + since = self.request.META.get('HTTP_IF_MODIFIED_SINCE', None) + if since is not None: + if not self.was_modified_since(self.file_instance, since): + return self.not_modified_response(*args, **kwargs) + # Return download response. + return self.download_response(*args, **kwargs) + + +class BaseDownloadView(DownloadMixin, View): + """A base :class:`DownloadMixin` that implements :meth:`get`.""" + def get(self, request, *args, **kwargs): + """Handle GET requests: stream a file.""" + return self.render_to_response() diff --git a/django_downloadview/views/http.py b/django_downloadview/views/http.py new file mode 100644 index 0000000..207840b --- /dev/null +++ b/django_downloadview/views/http.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Stream files given an URL, i.e. files you want to proxy.""" +import requests + +from django_downloadview.files import HTTPFile +from django_downloadview.views.base import BaseDownloadView + + +class HTTPDownloadView(BaseDownloadView): + """Proxy files that live on remote servers.""" + #: URL to download (the one we are proxying). + url = u'' + + #: Additional keyword arguments for request handler. + request_kwargs = {} + + def get_request_factory(self): + """Return request factory to perform actual HTTP request.""" + return requests.get + + def get_request_kwargs(self): + """Return keyword arguments for use with request factory.""" + return self.request_kwargs + + def get_url(self): + """Return remote file URL (the one we are proxying).""" + return self.url + + def get_file(self): + """Return wrapper which has an ``url`` attribute.""" + return HTTPFile(request_factory=self.get_request_factory(), + name=self.get_basename(), + url=self.get_url(), + **self.get_request_kwargs()) diff --git a/django_downloadview/views/object.py b/django_downloadview/views/object.py new file mode 100644 index 0000000..f590d2c --- /dev/null +++ b/django_downloadview/views/object.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""Stream files that live in models.""" +from django.views.generic.detail import BaseDetailView + +from django_downloadview.views.base import DownloadMixin + + +class ObjectDownloadView(DownloadMixin, BaseDetailView): + """Serve file fields from models. + + This class extends BaseDetailView, so you can use its arguments to target + the instance to operate on: slug, slug_kwarg, model, queryset... + See Django's DetailView reference for details. + + In addition to BaseDetailView arguments, you can set arguments related to + the file to be downloaded. + + The main one is ``file_field``. + + The other arguments are provided for convenience, in case your model holds + some (deserialized) metadata about the file, such as its basename, its + modification time, its MIME type... These fields may be particularly handy + if your file storage is not the local filesystem. + + """ + #: Name of the model's attribute which contains the file to be streamed. + #: Typically the name of a FileField. + file_field = 'file' + + #: Optional name of the model's attribute which contains the basename. + basename_field = None + + #: Optional name of the model's attribute which contains the encoding. + encoding_field = None + + #: Optional name of the model's attribute which contains the MIME type. + mime_type_field = None + + #: Optional name of the model's attribute which contains the charset. + charset_field = None + + #: Optional name of the model's attribute which contains the modification + # time. + modification_time_field = None + + #: Optional name of the model's attribute which contains the size. + size_field = None + + def get_file(self): + """Return :class:`~django.db.models.fields.files.FieldFile` instance. + + The file wrapper is model's field specified as :attr:`file_field`. It + is typically a :class:`~django.db.models.fields.files.FieldFile` or + subclass. + + Additional attributes are set on the file wrapper if :attr:`encoding`, + :attr:`mime_type`, :attr:`charset`, :attr:`modification_time` or + :attr:`size` are configured. + + """ + file_instance = getattr(self.object, self.file_field) + for field in ('encoding', 'mime_type', 'charset', 'modification_time', + 'size'): + model_field = getattr(self, '%s_field' % field, False) + if model_field: + value = getattr(self.object, model_field) + setattr(file_instance, field, value) + return file_instance + + def get_basename(self): + """Return client-side filename.""" + basename = super(ObjectDownloadView, self).get_basename() + if basename is None: + field = 'basename' + model_field = getattr(self, '%s_field' % field, False) + if model_field: + basename = getattr(self.object, model_field) + return basename diff --git a/django_downloadview/views/path.py b/django_downloadview/views/path.py new file mode 100644 index 0000000..1427142 --- /dev/null +++ b/django_downloadview/views/path.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +""":class:`PathDownloadView`.""" +from django.core.files import File + +from django_downloadview.views.base import BaseDownloadView + + +class PathDownloadView(BaseDownloadView): + """Serve a file using filename.""" + #: Server-side name (including path) of the file to serve. + #: + #: Filename is supposed to be an absolute filename of a file located on the + #: local filesystem. + path = None + + #: Name of the URL argument that contains path. + path_url_kwarg = 'path' + + def get_path(self): + """Return actual path of the file to serve. + + Default implementation simply returns view's :py:attr:`path`. + + Override this method if you want custom implementation. + As an example, :py:attr:`path` could be relative and your custom + :py:meth:`get_path` implementation makes it absolute. + + """ + return self.kwargs.get(self.path_url_kwarg, self.path) + + def get_file(self): + """Use path to return wrapper around file to serve.""" + return File(open(self.get_path())) diff --git a/django_downloadview/views/storage.py b/django_downloadview/views/storage.py new file mode 100644 index 0000000..56a1662 --- /dev/null +++ b/django_downloadview/views/storage.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +"""Stream files from storage.""" +from django.core.files.storage import DefaultStorage + +from django_downloadview.files import StorageFile +from django_downloadview.views.path import PathDownloadView + + +class StorageDownloadView(PathDownloadView): + """Serve a file using storage and filename.""" + #: Storage the file to serve belongs to. + storage = DefaultStorage() + + #: Path to the file to serve relative to storage. + path = None # Override docstring. + + def get_path(self): + """Return path of the file to serve, relative to storage. + + Default implementation simply returns view's :py:attr:`path` attribute. + + Override this method if you want custom implementation. + + """ + return super(StorageDownloadView, self).get_path() + + def get_file(self): + """Return :class:`~django_downloadview.files.StorageFile` instance.""" + return StorageFile(self.storage, self.get_path()) diff --git a/django_downloadview/views/virtual.py b/django_downloadview/views/virtual.py new file mode 100644 index 0000000..97dd040 --- /dev/null +++ b/django_downloadview/views/virtual.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""Stream files that you generate or that live in memory.""" +from django_downloadview.views.base import BaseDownloadView + + +class VirtualDownloadView(BaseDownloadView): + """Serve not-on-disk or generated-on-the-fly file. + + Use this class to serve :py:class:`StringIO` files. + + Override the :py:meth:`get_file` method to customize file wrapper. + + """ + def was_modified_since(self, file_instance, since): + """Delegate to file wrapper's was_modified_since, or return True. + + This is the implementation of an edge case: when files are generated + on the fly, we cannot guess whether they have been modified or not. + If the file wrapper implements ``was_modified_since()`` method, then we + trust it. Otherwise it is safer to suppose that the file has been + modified. + + This behaviour prevents file size to be computed on the Django side. + Because computing file size means iterating over all the file contents, + and we want to avoid that whenever possible. As an example, it could + reduce all the benefits of working with dynamic file generators... + which is a major feature of virtual files. + + """ + try: + return file_instance.was_modified_since(since) + except (AttributeError, NotImplementedError): + return True From a38876d15c06f21dbdb83e1b185551f5690db1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 14:59:25 +0200 Subject: [PATCH 11/25] Refs #42 - Introduced StringIteratorIO, for use in VirtualDownloadView when you need to stream generated content (yield). --- django_downloadview/io.py | 65 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 django_downloadview/io.py diff --git a/django_downloadview/io.py b/django_downloadview/io.py new file mode 100644 index 0000000..9eacc42 --- /dev/null +++ b/django_downloadview/io.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""Low-level IO operations, for use with file wrappers.""" +from __future__ import absolute_import +import io + + +class StringIteratorIO(io.TextIOBase): + """A dynamically generated StringIO-like object. + + Original code by Matt Joiner from: + + * http://stackoverflow.com/questions/12593576/adapt-an-iterator-to-behave-like-a-file-like-object-in-python + * https://gist.github.com/anacrolix/3788413 + + """ + def __init__(self, iterator): + self._iter = iterator + self._left = '' + + def readable(self): + return True + + def _read1(self, n=None): + while not self._left: + try: + self._left = next(self._iter) + except StopIteration: + break + ret = self._left[:n] + self._left = self._left[len(ret):] + return ret + + def read(self, n=None): + l = [] + if n is None or n < 0: + while True: + m = self._read1() + if not m: + break + l.append(m) + else: + while n > 0: + m = self._read1(n) + if not m: + break + n -= len(m) + l.append(m) + return ''.join(l) + + def readline(self): + l = [] + while True: + i = self._left.find('\n') + if i == -1: + l.append(self._left) + try: + self._left = next(self._iter) + except StopIteration: + self._left = '' + break + else: + l.append(self._left[:i + 1]) + self._left = self._left[i + 1:] + break + return ''.join(l) From 4547cc2f902b28f4953bfde76845b15a7781f526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 15:02:37 +0200 Subject: [PATCH 12/25] Refs #42 - Ignored Content-Length header for generated files. --- django_downloadview/files.py | 1 + django_downloadview/response.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/django_downloadview/files.py b/django_downloadview/files.py index 6d5c7eb..e1b53c4 100644 --- a/django_downloadview/files.py +++ b/django_downloadview/files.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """File wrappers for use as exchange data between views and responses.""" from django.core.files import File diff --git a/django_downloadview/response.py b/django_downloadview/response.py index 2745b56..49af040 100644 --- a/django_downloadview/response.py +++ b/django_downloadview/response.py @@ -1,4 +1,5 @@ -"""HttpResponse subclasses.""" +# -*- coding: utf-8 -*- +""":py:class:`django.http.HttpResponse` subclasses.""" import os import mimetypes @@ -9,10 +10,10 @@ from django.http import HttpResponse, StreamingHttpResponse class DownloadResponse(StreamingHttpResponse): """File download response. - ``content`` attribute is supposed to be a file object wrapper, which makes - this response "lazy". + :py:attr:`content` attribute is supposed to be a file object wrapper, which + makes this response lazy. - This is a specialization of Django's :py:class:`StreamingHttpResponse`. + This is a specialization of :py:class:`django.http.StreamingHttpResponse`. """ def __init__(self, file_instance, attachment=True, basename=None, @@ -71,7 +72,10 @@ class DownloadResponse(StreamingHttpResponse): except AttributeError: headers = {} headers['Content-Type'] = self.get_content_type() - headers['Content-Length'] = self.file.size + try: + headers['Content-Length'] = self.file.size + except (AttributeError, NotImplementedError): + pass # Generated files. if self.attachment: headers['Content-Disposition'] = 'attachment; filename=%s' \ % self.get_basename() @@ -126,6 +130,7 @@ class ProxiedDownloadResponse(HttpResponse): """Base class for internal redirect download responses. This base class makes it possible to identify several types of specific - responses such as :py:class:`django_downloadview.XAccelRedirectResponse`. + responses such as + :py:class:`~django_downloadview.nginx.response.XAccelRedirectResponse`. """ From 47cc4ce04d140f13de02caec59afbc6b84ea391d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 15:04:30 +0200 Subject: [PATCH 13/25] Refs #41 - Introduced experimental port of django-sendfile's sendfile() function. --- django_downloadview/sendfile.py | 23 ++++++++++++++ docs/about/alternatives.txt | 53 +++++++++++++++++++++------------ 2 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 django_downloadview/sendfile.py diff --git a/django_downloadview/sendfile.py b/django_downloadview/sendfile.py new file mode 100644 index 0000000..2031306 --- /dev/null +++ b/django_downloadview/sendfile.py @@ -0,0 +1,23 @@ +# -*- 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/docs/about/alternatives.txt b/docs/about/alternatives.txt index 4eb8f90..0cdd63e 100644 --- a/docs/about/alternatives.txt +++ b/docs/about/alternatives.txt @@ -30,30 +30,45 @@ django-sendfile `django-sendfile`_ is a wrapper around web-server specific methods for sending files to web clients. -API is made of a single ``sendfile()`` function, which returns a download -response. The download response type depends on the chosen backend, which could -be Django, Lighttpd's X-Sendfile, Nginx's X-Accel... +``django-senfile``'s main focus is simplicity: API is made of a single +``sendfile()`` function you call inside your views: -It seems that django-senfile main focus is simplicity: you call the -``sendfile()`` method inside your views. +.. code:: python -Django-downloadview main focus is reusability: you configure (or override) -class-based views depending on the use case. + from sendfile import sendfile + + def hello_world(request): + """Send 'hello-world.pdf' file as a response.""" + return sendfile(request, '/path/to/hello-world.pdf') + +The download response type depends on the chosen backend, which could +be Django, Lighttpd's X-Sendfile, Nginx's X-Accel... depending your settings: + +.. code:: python + + SENDFILE_BACKEND = 'sendfile.backends.nginx' # sendfile() will return + # X-Accel responses. + # Additional settings for sendfile's nginx backend. + SENDFILE_ROOT = '/path/to' + SENDFILE_URL = '/proxied-download' + +Here are main differences between the two projects: + +* ``django-sendfile`` supports only files that live on local filesystem (i.e. + where ``os.path.exists`` returns ``True``). Whereas ``django-downloadview`` + allows you to serve or proxy files stored in various locations, including + remote ones. + +* ``django-sendfile`` uses a single global configuration (i.e. + ``settings.SENDFILE_ROOT``), thus optimizations are limited to a single + 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 local -filesystem**, because the ``sendfile()`` method only accepts filenames relative -to local filesystem (i.e. using ``os.path.exists``). +may be a good alternative **provided you serve files that live in a single +directory of local filesystem**. -Django-downloadview (since version 1.1) handles file wrappers, and thus allows -you to serve files from more locations: - -* models, -* storages, -* local filesystem, -* remote URL (using `requests`_), -* in-memory (or generated) files (such as StringIO), -* ... and your custom ones with little efforts. +:func:`django_downloadview.sendfile` is a port of django-sendfile's main function. ******************** From cb68d7f8e54788d70be5248e563fa0e9b917b24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 15:05:21 +0200 Subject: [PATCH 14/25] Added few lines about vision in documentation. --- docs/about/index.txt | 1 + docs/about/vision.txt | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 docs/about/vision.txt diff --git a/docs/about/index.txt b/docs/about/index.txt index cee816a..becee9a 100644 --- a/docs/about/index.txt +++ b/docs/about/index.txt @@ -4,6 +4,7 @@ About django-downloadview .. toctree:: + vision alternatives license authors diff --git a/docs/about/vision.txt b/docs/about/vision.txt new file mode 100644 index 0000000..7b5f8d7 --- /dev/null +++ b/docs/about/vision.txt @@ -0,0 +1,26 @@ +###### +Vision +###### + +`django-downloadview` tries to simplify the development of "download" views +using `Django`_ framework. It provides generic views that cover most common +patterns. + +Django is not the best solution to serve files: reverse proxies are far more +efficient. `django-downloadview` makes it easy to implement this best-practice. + +Tests matter: `django-downloadview` provides tools to test download views and +optimizations. + + +.. rubric:: Notes & references + +.. seealso:: + + * :doc:`/about/alternatives` + * `roadmap + `_ + +.. target-notes:: + +.. _`Django`: https://django-project.com From 413f7a90520b6ea15078133a51e0fc2b474f254e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 21 Oct 2013 15:06:19 +0200 Subject: [PATCH 15/25] Refs #39 - Improved narrative documentation. Work in progress. --- docs/files.txt | 89 +++++++++++++++++++++++ docs/healthchecks.txt | 106 +++++++++++++++++++++++++++ docs/index.txt | 9 ++- docs/optimizations/index.txt | 6 +- docs/overview.txt | 91 ++++++++++++++++++++++++ docs/responses.txt | 16 +++++ docs/testing.txt | 6 +- docs/views.txt | 113 ----------------------------- docs/views/custom.txt | 28 ++++++++ docs/views/http.txt | 43 +++++++++++ docs/views/index.txt | 22 ++++++ docs/views/object.txt | 98 +++++++++++++++++++++++++ docs/views/path.txt | 104 +++++++++++++++++++++++++++ docs/views/storage.txt | 62 ++++++++++++++++ docs/views/virtual.txt | 134 +++++++++++++++++++++++++++++++++++ 15 files changed, 805 insertions(+), 122 deletions(-) create mode 100644 docs/files.txt create mode 100644 docs/healthchecks.txt create mode 100644 docs/overview.txt create mode 100644 docs/responses.txt delete mode 100644 docs/views.txt create mode 100644 docs/views/custom.txt create mode 100644 docs/views/http.txt create mode 100644 docs/views/index.txt create mode 100644 docs/views/object.txt create mode 100644 docs/views/path.txt create mode 100644 docs/views/storage.txt create mode 100644 docs/views/virtual.txt diff --git a/docs/files.txt b/docs/files.txt new file mode 100644 index 0000000..7511040 --- /dev/null +++ b/docs/files.txt @@ -0,0 +1,89 @@ +############# +File wrappers +############# + +.. py:module:: django_downloadview.files + +A view return :class:`~django_downloadview.response.DownloadResponse` which +itself carries a file wrapper. Here are file wrappers distributed by Django +and django-downloadview. + + +***************** +Django's builtins +***************** + +`Django itself provides some file wrappers`_ you can use within +``django-downloadview``: + +* :py:class:`django.core.files.File` wraps a file that live on local + filesystem, initialized with a path. ``django-downloadview`` uses this + wrapper in :doc:`/views/path`. + +* :py:class:`django.db.models.fields.files.FieldFile` wraps a file that is + managed in a model. ``django-downloadview`` uses this wrapper in + :doc:`/views/object`. + +* :py:class:`django.core.files.base.ContentFile` wraps a bytes, string or + unicode object. You may use it with :doc:`VirtualDownloadView + `. + + +**************************** +django-downloadview builtins +**************************** + +``django-downloadview`` implements additional file wrappers: + +* :class:`StorageFile` wraps a file that is + managed via a storage (but not necessarily via a model). + :doc:`/views/storage` uses this wrapper. + +* :class:`HTTPFile` wraps a file that lives at + some (remote) location, initialized with an URL. + :doc:`/views/http` uses this wrapper. + +* :class:`VirtualFile` wraps a file that lives in + memory, i.e. built as a string. + This is a convenient wrapper to use in :doc:`/views/virtual` subclasses. + + +************* +API reference +************* + +StorageFile +=========== + +.. autoclass:: StorageFile + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +HTTPFile +======== + +.. autoclass:: HTTPFile + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + + +VirtualFile +=========== + +.. autoclass:: VirtualFile + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + + +.. rubric:: Notes & references + +.. target-notes:: + +.. _`Django itself provides some file wrappers`: + https://docs.djangoproject.com/en/1.5/ref/files/file/ diff --git a/docs/healthchecks.txt b/docs/healthchecks.txt new file mode 100644 index 0000000..48e3fd1 --- /dev/null +++ b/docs/healthchecks.txt @@ -0,0 +1,106 @@ +################## +Write healthchecks +################## + +In the previous :doc:`testing ` topic, you made sure the views and +middlewares work as expected... within a test environment. + +One common issue when deploying in production is that the reverse-proxy's +configuration does not fit. You cannot check that within test environment. + +**Healthchecks are made to diagnose issues in live (production) environments**. + + +************************ +Introducing healthchecks +************************ + +Healthchecks (sometimes called "smoke tests" or "diagnosis") are assertions you +run on a live (typically production) service, as opposed to fake/mock service +used during tests (unit, integration, functional). + +See `hospital`_ and `django-doctor`_ projects about writing healthchecks for +Python and Django. + + +******************** +Typical healthchecks +******************** + +Here is a typical healthcheck setup for download views with reverse-proxy +optimizations. + +When you run this healthcheck suite, you get a good overview if a problem +occurs: you can compare expected results and learn which part (Django, +reverse-proxy or remote storage) is guilty. + +.. note:: + + In the examples below, we use "localhost" and ports "80" (reverse-proxy) or + "8000" (Django). Adapt them to your configuration. + +Check storage +============= + +Put a dummy file on the storage Django uses. + +The write a healthcheck that asserts you can read the dummy file from storage. + +**On success, you know remote storage is ok.** + +Issues may involve permissions or communications (remote storage). + +.. note:: + + This healthcheck may be outside Django. + +Check Django VS storage +======================= + +Implement a download view dedicated to healthchecks. It is typically a public +(but not referenced) view that streams a dummy file from real storage. +Let's say you register it as ``/healthcheck-utils/download/`` URL. + +Write a healthcheck that asserts ``GET +http://localhost:8000/healtcheck-utils/download/`` (notice the `8000` port: +local Django server) returns the expected reverse-proxy response (X-Accel, +X-Sendfile...). + +**On success, you know there is no configuration issue on the Django side.** + +Check reverse proxy VS storage +============================== + +Write a location in your reverse-proxy's configuration that proxy-pass to a +dummy file on storage. + +Write a healthcheck that asserts this location returns the expected dummy file. + +**On success, you know the reverse proxy can serve files from storage.** + +Check them all together +======================= + +We just checked all parts separately, so let's make sure they can work +together. +Configure the reverse-proxy so that `/healthcheck-utils/download/` is proxied +to Django. Then write a healthcheck that asserts ``GET +http://localhost:80/healthcheck-utils/download`` (notice the `80` port: +reverse-proxy server) returns the expected dummy file. + +**On success, you know everything is ok.** + +On failure, there is an issue in the X-Accel/X-Sendfile configuration. + +.. note:: + + This last healthcheck should be the first one to run, i.e. if it passes, + others should pass too. The others are useful when this one fails. + + +.. rubric:: Notes & references + +.. target-notes:: + +.. _`hospital`: https://pypi.python.org/pypi/hospital +.. _`django-doctor`: https://pypi.python.org/pypi/django-doctor diff --git a/docs/index.txt b/docs/index.txt index fd2a3d5..38aafc5 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -9,12 +9,15 @@ Contents :maxdepth: 2 :titlesonly: - demo + overview install settings - views + views/index optimizations/index testing - api/index + healthchecks + files + responses + demo about/index dev diff --git a/docs/optimizations/index.txt b/docs/optimizations/index.txt index 34dc9bf..34040d3 100644 --- a/docs/optimizations/index.txt +++ b/docs/optimizations/index.txt @@ -1,6 +1,6 @@ -############# -Optimizations -############# +################## +Optimize streaming +################## Some reverse proxies allow applications to delegate actual download to the proxy: diff --git a/docs/overview.txt b/docs/overview.txt new file mode 100644 index 0000000..810f1e0 --- /dev/null +++ b/docs/overview.txt @@ -0,0 +1,91 @@ +################## +Overview, concepts +################## + +Given: + +* you manage files with Django (permissions, search, generation, ...) + +* files are stored somewhere or generated somehow (local filesystem, remote + storage, memory...) + +As a developer, you want to serve files quick and efficiently. + +Here is an overview of ``django-downloadview``'s answer... + + +************************************ +Generic views cover commons patterns +************************************ + +* :doc:`/views/object` when you have a model with a file field; +* :doc:`/views/storage` when you manage files in a storage; +* :doc:`/views/path` when you have an absolute filename on local filesystem; +* :doc:`/views/http` when you have an URL (the resource is proxied); +* :doc:`/views/virtual` when you generate a file dynamically. + + +************************************************* +Generic views and mixins allow easy customization +************************************************* + +If your use case is a bit specific, you can easily extend the views above or +:doc:`create your own based on mixins `. + + +***************************** +Views return DownloadResponse +***************************** + +Views return :py:class:`~django_downloadview.response.DownloadResponse`. It is +a special :py:class:`django.http.StreamingHttpResponse` where content is +encapsulated in a file wrapper. If the response is sent to the client, the file +content content is loaded. + +.. note:: + + Middlewares and decorators are given the opportunity to optimize the + streaming before file content loading. + +Learn more in :doc:`responses`. + + +*********************************** +DownloadResponse carry file wrapper +*********************************** + +A download view instanciates a :doc:`file wrapper ` and use it to +initialize :py:class:`~django_downloadview.response.DownloadResponse`. + +File wrappers describe files. They carry files properties, but not file +content. They implement loading and iterating over file content. + +Learn more about available file wrappers in :doc:`files`. + + +***************************************************************** +Middlewares convert DownloadResponse into ProxiedDownloadResponse +***************************************************************** + +Decorators and middlewares may capture +:py:class:`~django_downloadview.response.DownloadResponse` instances in order +to optimize the streaming. A good optimization is to delegate streaming to +reverse proxies such as Nginx via X-Accel redirections or Apache via +X-Sendfile. + +Learn more in :doc:`optimizations/index`. + + +*************** +Testing matters +*************** + +``django-downloadview`` also helps you :doc:`test the views you customized +`. + + +************ +What's next? +************ + +Convinced? Let's :doc:`install django-downloadview `. diff --git a/docs/responses.txt b/docs/responses.txt new file mode 100644 index 0000000..a028d7f --- /dev/null +++ b/docs/responses.txt @@ -0,0 +1,16 @@ +######### +Responses +######### + + +******************** +``DownloadResponse`` +******************** + + + +*************************** +``ProxiedDownloadResponse`` +*************************** + + diff --git a/docs/testing.txt b/docs/testing.txt index 94118ac..0b749a3 100644 --- a/docs/testing.txt +++ b/docs/testing.txt @@ -1,6 +1,6 @@ -###################### -Testing download views -###################### +########### +Write tests +########### This project includes shortcuts to simplify testing. diff --git a/docs/views.txt b/docs/views.txt deleted file mode 100644 index 48f9b69..0000000 --- a/docs/views.txt +++ /dev/null @@ -1,113 +0,0 @@ -############## -Download views -############## - -This section contains narrative overview about class-based views provided by -django-downloadview. - -By default, all of those views would stream the file to the client. -But keep in mind that you can setup :doc:`/optimizations/index` to delegate -actual streaming to a reverse proxy. - - -************* -DownloadMixin -************* - -The :py:class:`django_downloadview.views.DownloadMixin` class is not a view. It -is a base class which you can inherit of to create custom download views. - -``DownloadMixin`` is a base of `BaseDownloadView`_, which itself is a base of -all other django_downloadview's builtin views. - - -**************** -BaseDownloadView -**************** - -The :py:class:`django_downloadview.views.BaseDownloadView` class is a base -class to create download views. It inherits `DownloadMixin`_ and -:py:class:`django.views.generic.base.View`. - -The only thing it does is to implement -:py:meth:`get `: it triggers -:py:meth:`DownloadMixin's render_to_response -`. - - -****************** -ObjectDownloadView -****************** - -The :py:class:`django_downloadview.views.ObjectDownloadView` class-based view -allows you to **serve files given a model with some file fields** such as -FileField or ImageField. - -Use this view anywhere you could use Django's builtin ObjectDetailView. - -Some options allow you to store file metadata (size, content-type, ...) in the -model, as deserialized fields. - - -******************* -StorageDownloadView -******************* - -The :py:class:`django_downloadview.views.StorageDownloadView` class-based view -allows you to **serve files given a storage and a path**. - -Use this view when you manage files in a storage (which is a good practice), -unrelated to a model. - - -**************** -PathDownloadView -**************** - -The :py:class:`django_downloadview.views.PathDownloadView` class-based view -allows you to **serve files given an absolute path on local filesystem**. - -Two main use cases: - -* as a shortcut. This dead-simple view is straight to call, so you can use it - to simplify code in more complex views, provided you have an absolute path to - a local file. - -* override. Extend :py:class:`django_downloadview.views.PathDownloadView` and - override :py:meth:`django_downloadview.views.PathDownloadView:get_path`. - - -**************** -HTTPDownloadView -**************** - -The :py:class:`django_downloadview.views.HTTPDownloadView` class-based view -allows you to **serve files given an URL**. That URL is supposed to be -downloadable from the Django server. - -Use it when you want to setup a proxy to remote files: - -* the Django view filters input and computes target URL. -* if you setup optimizations, Django itself doesn't proxies the file, -* but, as a fallback, Django uses `requests`_ to proxy the file. - -Extend :py:class:`django_downloadview.views.HTTPDownloadView` then -override :py:meth:`django_downloadview.views.HTTPDownloadView:get_url`. - - -******************* -VirtualDownloadView -******************* - -The :py:class:`django_downloadview.views.VirtualDownloadView` class-based view -allows you to **serve files that don't live on disk**. - -Use it when you want to stream a file which content is dynamically generated -or which lives in memory. - - -.. rubric:: References - -.. target-notes:: - -.. _`requests`: https://pypi.python.org/pypi/requests diff --git a/docs/views/custom.txt b/docs/views/custom.txt new file mode 100644 index 0000000..3b3a204 --- /dev/null +++ b/docs/views/custom.txt @@ -0,0 +1,28 @@ +################## +Make your own view +################## + + +************* +DownloadMixin +************* + +The :py:class:`django_downloadview.views.DownloadMixin` class is not a view. It +is a base class which you can inherit of to create custom download views. + +``DownloadMixin`` is a base of `BaseDownloadView`_, which itself is a base of +all other django_downloadview's builtin views. + + +**************** +BaseDownloadView +**************** + +The :py:class:`django_downloadview.views.BaseDownloadView` class is a base +class to create download views. It inherits `DownloadMixin`_ and +:py:class:`django.views.generic.base.View`. + +The only thing it does is to implement +:py:meth:`get `: it triggers +:py:meth:`DownloadMixin's render_to_response +`. diff --git a/docs/views/http.txt b/docs/views/http.txt new file mode 100644 index 0000000..294db2e --- /dev/null +++ b/docs/views/http.txt @@ -0,0 +1,43 @@ +################ +HTTPDownloadView +################ + +.. py:module:: django_downloadview.views.http + +:class:`HTTPDownloadView` **serves a file given an URL.**, i.e. it acts like +a proxy. + +This view is particularly handy when: + +* the client does not have access to the file resource, while your Django + server does. + +* the client does trust your server, your server trusts a third-party, you do + not want to bother the client with the third-party. + + +************** +Simple example +************** + +Setup a view to stream files given URL: + +.. code:: python + + from django_downloadview import HTTPDownloadView + + class TravisStatusView(HTTPDownloadView): + def get_url(self): + """Return URL of django-downloadview's build status.""" + return u'https://travis-ci.org/benoitbryon/django-downloadview.png' + + +************* +API reference +************* + +.. autoclass:: HTTPDownloadView + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/views/index.txt b/docs/views/index.txt new file mode 100644 index 0000000..8590ca5 --- /dev/null +++ b/docs/views/index.txt @@ -0,0 +1,22 @@ +########### +Setup views +########### + +Setup views depending on your needs: + +* :doc:`/views/object` when you have a model with a file field; +* :doc:`/views/storage` when you manage files in a storage; +* :doc:`/views/path` when you have an absolute filename on local filesystem; +* :doc:`/views/http` when you have an URL (the resource is proxied); +* :doc:`/views/virtual` when you generate a file dynamically; +* :doc:`bases and mixins ` to make your own. + +.. toctree:: + :hidden: + + object + storage + path + http + virtual + custom diff --git a/docs/views/object.txt b/docs/views/object.txt new file mode 100644 index 0000000..d84c48f --- /dev/null +++ b/docs/views/object.txt @@ -0,0 +1,98 @@ +################## +ObjectDownloadView +################## + +.. py:module:: django_downloadview.views.object + +:class:`ObjectDownloadView` **serves files managed in models with file fields** +such as :class:`~django.db.models.FileField` or +:class:`~django.db.models.ImageField`. + +Use this view like Django's builtin +:class:`~django.views.generic.detail.DetailView`. + +Additional options allow you to store file metadata (size, content-type, ...) +in the model, as deserialized fields. + + +************** +Simple example +************** + +Given a model with a :class:`~django.db.models.FileField`: + +.. code:: python + + from django.db import models + + class Document(models.Model): + file = models.FileField(upload_to='document') + +Setup a view to stream the ``file`` attribute: + +.. code:: python + + from django_downloadview import ObjectDownloadView + + download = ObjectDownloadView.as_view(model=Document) + +.. note:: + + If the file field you want to serve is not named "file", pass the right + name as "file_field" argument, i.e. adapt + ``ObjectDownloadView.as_view(model=Document, file_field='file')``. + +:class:`~django_downloadview.views.object.ObjectDownloadView` inherits from +:class:`~django.views.generic.detail.BaseDetailView`, i.e. it expects either +``slug`` or ``pk``: + +.. code:: python + + from django.conf.urls import url, url_patterns + + url_patterns = ('', + url('^download/(?P[A-Za-z0-9_-]+)/$', download, name='download'), + ) + + +********************************** +Mapping file attributes to model's +********************************** + +Sometimes, you use Django model to store file's metadata. Some of this metadata +can be used when you serve the file. + +As an example, let's consider the client-side basename lives in model and not +in storage: + +.. code:: python + + from django.db import models + + class Document(models.Model): + file = models.FileField(upload_to='document') + basename = models.CharField(max_length=100) + +Then you can configure the :attr:`ObjectDownloadView.basename_field` option: + +.. code:: python + + from django_downloadview import ObjectDownloadView + + download = ObjectDownloadView.as_view(model=Document, + basename_field='basename') + +.. note:: ``basename`` could have been a property instead of a database field. + +See details below for a full list of options. + + +************* +API reference +************* + +.. autoclass:: ObjectDownloadView + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/views/path.txt b/docs/views/path.txt new file mode 100644 index 0000000..10a95ed --- /dev/null +++ b/docs/views/path.txt @@ -0,0 +1,104 @@ +################ +PathDownloadView +################ + +.. py:module:: django_downloadview.views.path + +:class:`PathDownloadView` **serves file given a path on local filesystem**. + +Use this view whenever you just have a path, outside storage or model. + +.. warning:: + + Take care of path validation, especially if you compute paths from user + input: an attacker may be able to download files from arbitrary locations. + In most cases, you should consider managing files in storages, because they + implement default security mechanisms. + + +************** +Simple example +************** + +Setup a view to stream files given path: + +.. code:: python + + from django_downloadview import PathDownloadView + + download = PathDownloadView.as_view() + +The view accepts a ``path`` argument you can setup either in ``as_view`` or +via URLconfs: + +.. code:: python + + from django.conf.urls import patterns, url + + urlpatterns = patterns( + '', + url(r'^(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', download), + ) + + +************************************ +A convenient shortcut in other views +************************************ + +:class:`PathDownloadView` is straight to call, so you can use it to simplify +code in more complex views, provided you have an absolute path to a local file: + +.. code:: python + + from django_downloadview import PathDownloadView + + def some_complex_view(request, *args, **kwargs): + """Does many things, then stream a file.""" + local_path = do_many_things() + return PathDownloadView.as_view(path=local_path)(request) + +.. note:: + + `django-sendfile`_ users may like something such as + ``sendfile = lambda request, path: PathDownloadView.as_view(path=path)(request)`` + + +************************** +Computing path dynamically +************************** + +Override the :meth:`PathDownloadView.get_path` method to adapt path +resolution to your needs: + +.. code:: python + + import glob + import os + import random + from django.conf import settings + from django_downloadview import PathDownloadView + + class RandomImageView(PathDownloadView): + """Stream a random image in ``MEDIA_ROOT``.""" + def get_path(self): + """Return the path of a random JPG image in ``MEDIA_ROOT``.""" + image_list = glob.glob(os.path.join(settings.MEDIA_ROOT, '*.jpg')) + return random.choice(image_list) + + +************* +API reference +************* + +.. autoclass:: PathDownloadView + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + + +.. rubric:: Notes & references + +.. target-notes:: + +.. _`django-sendfile`: https://pypi.python.org/pypi/django-sendfile diff --git a/docs/views/storage.txt b/docs/views/storage.txt new file mode 100644 index 0000000..fc35f37 --- /dev/null +++ b/docs/views/storage.txt @@ -0,0 +1,62 @@ +################### +StorageDownloadView +################### + +.. py:module:: django_downloadview.views.storage + +:class:`StorageDownloadView` **serves files given a storage and a path**. + +Use this view when you manage files in a storage (which is a good practice), +unrelated to a model. + + +************** +Simple example +************** + +Given a storage: + +.. code:: python + + from django.core.files.storage import FileSystemStorage + + storage = FileSystemStorage(location='/somewhere') + +Setup a view to stream files in storage: + +.. code:: python + + from django_downloadview import StorageDownloadView + + download = StorageDownloadView.as_view(storage=storage) + +The view accepts a ``path`` argument you can setup either in ``as_view`` or +via URLconfs: + +.. code:: python + + from django.conf.urls import patterns, url + + urlpatterns = patterns( + '', + url(r'^(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', download), + ) + + +************************** +Computing path dynamically +************************** + +Override the :meth:`StorageDownloadView.get_path` method to adapt path +resolution to your needs. + + +************* +API reference +************* + +.. autoclass:: StorageDownloadView + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/views/virtual.txt b/docs/views/virtual.txt new file mode 100644 index 0000000..ce77049 --- /dev/null +++ b/docs/views/virtual.txt @@ -0,0 +1,134 @@ +################### +VirtualDownloadView +################### + +.. py:module:: django_downloadview.views.virtual + +:class:`VirtualDownloadView` **serves files that do not live on disk**. +Use it when you want to stream a file which content is dynamically generated +or which lives in memory. + +It is all about overriding :meth:`VirtualDownloadView.get_file` method so that +it returns a suitable file wrapper... + +.. note:: + + Current implementation does not support reverse-proxy optimizations, + because there is no place reverse-proxy can load files from after Django + exited. + + +*************************************** +Serve text (string or unicode) or bytes +*************************************** + +Let's consider you build text dynamically, as a bytes or string or unicode +object. Serve it with Django's builtin +:class:`~django.core.files.base.ContentFile` wrapper: + +.. code:: python + + from django.core.files.base import ContentFile + from django_downloadview import VirtualDownloadView + + class TextDownloadView(VirtualDownloadView): + def get_file(self): + """Return :class:`django.core.files.base.ContentFile` object.""" + return ContentFile(u"Hello world!", name='hello-world.txt') + + +************** +Serve StringIO +************** + +:class:`~StringIO.StringIO` object lives in memory. Let's wrap it in some +download view via :class:`~django_downloadview.files.VirtualFile`: + +.. code:: python + + from StringIO import StringIO + from django_downloadview import VirtualDownloadView, VirtualFile + + class StringIODownloadView(VirtualDownloadView): + def get_file(self): + """Return wrapper on ``StringIO`` object.""" + file_obj = StringIO(u"Hello world!\n".encode('utf-8')) + return VirtualFile(file_obj, name='hello-world.txt') + + +************************ +Stream generated content +************************ + +Let's consider you have a generator function (``yield``) or an iterator object +(``__iter__()``): + +.. code:: python + + def generate_hello(): + yield u'Hello ' + yield u'world!' + +Stream generated content using :class:`VirtualDownloadView`, +:class:`~django_downloadview.files.VirtualFile` and +:class:`~django_downloadview.file.StringIteratorIO`: + +.. code:: python + + from django_downloadview import (VirtualDownloadView, + VirtualFile, + StringIteratorIO) + + class GeneratedDownloadView(VirtualDownloadView): + def get_file(self): + """Return wrapper on ``StringIteratorIO`` object.""" + file_obj = StringIteratorIO(generate_hello()) + return VirtualFile(file_obj, name='hello-world.txt') + + +************************************ +Handling http not modified responses +************************************ + +Sometimes, you know the latest date and time the content was generated at, and +you know a new request would generate exactly the same content. In such a case, +you should implement :py:meth:`~VirtualDownloadView.was_modified_since` in your +view. + +.. note:: + + Default :py:meth:`~VirtualDownloadView.was_modified_since` implementation + trusts file wrapper's ``was_modified_since`` if any. Else (if calling + ``was_modified_since()`` raises ``NotImplementedError`` or + ``AttributeError``) it returns ``True``, i.e. it assumes the file was + modified. + +As an example, the download views above always generate "Hello world!"... so, +if the client already downloaded it, we can safely return some HTTP "304 Not +Modified" response: + +.. code:: python + + from django.core.files.base import ContentFile + from django_downloadview import VirtualDownloadView + + class TextDownloadView(VirtualDownloadView): + def get_file(self): + """Return :class:`django.core.files.base.ContentFile` object.""" + return ContentFile(u"Hello world!", name='hello-world.txt') + + def was_modified_since(self, file_instance, since): + return False # Never modified, always u"Hello world!". + + + + +************* +API reference +************* + +.. autoclass:: VirtualDownloadView + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource From 874f3b9b544c08edc731085359f74d6838e16b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 28 Oct 2013 16:58:18 +0100 Subject: [PATCH 16/25] Refs #25, refs #39, refs #40, refs #42 - Big refactoring in documentation and demo: narrative documentation uses examples from demo project. --- AUTHORS | 4 +- LICENSE | 2 +- Makefile | 11 +- demo/README | 58 ++----- demo/demoproject/download/__init__.py | 1 - demo/demoproject/download/models.py | 7 - demo/demoproject/download/tests.py | 107 ------------ demo/demoproject/download/urls.py | 34 ---- demo/demoproject/download/views.py | 90 ---------- .../{download => }/fixtures/demo.json | 2 +- .../{download => }/fixtures/hello-world.txt | 0 demo/demoproject/http/__init__.py | 7 + demo/demoproject/http/models.py | 1 + demo/demoproject/http/tests.py | 16 ++ demo/demoproject/http/urls.py | 11 ++ demo/demoproject/http/views.py | 12 ++ demo/demoproject/nginx/__init__.py | 2 +- demo/demoproject/nginx/models.py | 1 + demo/demoproject/nginx/tests.py | 61 +++---- demo/demoproject/nginx/urls.py | 11 +- demo/demoproject/nginx/views.py | 26 +-- demo/demoproject/object/__init__.py | 7 + demo/demoproject/object/models.py | 8 + demo/demoproject/object/tests.py | 70 ++++++++ demo/demoproject/object/urls.py | 17 ++ demo/demoproject/object/views.py | 18 ++ demo/demoproject/path/__init__.py | 7 + demo/demoproject/path/models.py | 1 + demo/demoproject/path/tests.py | 28 ++++ demo/demoproject/path/urls.py | 14 ++ demo/demoproject/path/views.py | 39 +++++ demo/demoproject/settings.py | 16 +- demo/demoproject/storage/__init__.py | 7 + demo/demoproject/storage/models.py | 1 + demo/demoproject/storage/storage.py | 4 + demo/demoproject/storage/tests.py | 48 ++++++ demo/demoproject/storage/urls.py | 14 ++ demo/demoproject/storage/views.py | 20 +++ demo/demoproject/templates/home.html | 17 +- demo/demoproject/urls.py | 26 ++- demo/demoproject/virtual/__init__.py | 7 + demo/demoproject/virtual/models.py | 1 + demo/demoproject/virtual/tests.py | 40 +++++ demo/demoproject/virtual/urls.py | 17 ++ demo/demoproject/virtual/views.py | 33 ++++ django_downloadview/api.py | 6 +- django_downloadview/decorators.py | 4 +- django_downloadview/files.py | 44 ++++- django_downloadview/middlewares.py | 14 +- django_downloadview/nginx/decorators.py | 16 +- django_downloadview/nginx/middlewares.py | 8 +- django_downloadview/response.py | 84 ++++++---- django_downloadview/test.py | 25 ++- django_downloadview/views/http.py | 18 +- django_downloadview/views/virtual.py | 2 - docs/conf.py | 1 + docs/dev.txt | 17 +- docs/optimizations/index.txt | 62 ++----- docs/optimizations/nginx.txt | 154 +++++++++--------- docs/overview.txt | 64 +++++--- docs/responses.txt | 28 +++- docs/settings.txt | 28 ++-- docs/testing.txt | 30 +++- docs/views/custom.txt | 48 ++++++ docs/views/http.txt | 10 +- docs/views/object.txt | 69 ++++---- docs/views/path.txt | 70 ++------ docs/views/storage.txt | 34 ++-- docs/views/virtual.txt | 84 ++-------- 69 files changed, 1040 insertions(+), 804 deletions(-) delete mode 100644 demo/demoproject/download/__init__.py delete mode 100644 demo/demoproject/download/models.py delete mode 100644 demo/demoproject/download/tests.py delete mode 100644 demo/demoproject/download/urls.py delete mode 100644 demo/demoproject/download/views.py rename demo/demoproject/{download => }/fixtures/demo.json (79%) rename demo/demoproject/{download => }/fixtures/hello-world.txt (100%) create mode 100644 demo/demoproject/http/__init__.py create mode 100644 demo/demoproject/http/models.py create mode 100644 demo/demoproject/http/tests.py create mode 100644 demo/demoproject/http/urls.py create mode 100644 demo/demoproject/http/views.py create mode 100644 demo/demoproject/object/__init__.py create mode 100644 demo/demoproject/object/models.py create mode 100644 demo/demoproject/object/tests.py create mode 100644 demo/demoproject/object/urls.py create mode 100644 demo/demoproject/object/views.py create mode 100644 demo/demoproject/path/__init__.py create mode 100644 demo/demoproject/path/models.py create mode 100644 demo/demoproject/path/tests.py create mode 100644 demo/demoproject/path/urls.py create mode 100644 demo/demoproject/path/views.py create mode 100644 demo/demoproject/storage/__init__.py create mode 100644 demo/demoproject/storage/models.py create mode 100644 demo/demoproject/storage/storage.py create mode 100644 demo/demoproject/storage/tests.py create mode 100644 demo/demoproject/storage/urls.py create mode 100644 demo/demoproject/storage/views.py create mode 100644 demo/demoproject/virtual/__init__.py create mode 100644 demo/demoproject/virtual/models.py create mode 100644 demo/demoproject/virtual/tests.py create mode 100644 demo/demoproject/virtual/urls.py create mode 100644 demo/demoproject/virtual/views.py diff --git a/AUTHORS b/AUTHORS index a17df29..d45879e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,5 +9,7 @@ Original code by `Novapost `_ team: * Nicolas Tobo * Lauréline Guérin * Gregory Tappero +* Rémy Hubscher * Benoît Bryon -* Rémy Hubscher \ No newline at end of file + +Developers: https://github.com/benoitbryon/django-downloadview/graphs/contributors diff --git a/LICENSE b/LICENSE index 7bb8fc0..8a9f051 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,7 @@ License ####### -Copyright (c) 2012, Benoît Bryon. +Copyright (c) 2012-2013, Benoît Bryon. All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/Makefile b/Makefile index 92905a0..f7386d1 100644 --- a/Makefile +++ b/Makefile @@ -72,10 +72,15 @@ documentation: sphinx demo: develop - mkdir -p var/media/document $(BIN_DIR)/demo syncdb --noinput - cp $(ROOT_DIR)/demo/demoproject/download/fixtures/hello-world.txt var/media/document/ - $(BIN_DIR)/demo loaddata $(ROOT_DIR)/demo/demoproject/download/fixtures/demo.json + # Install fixtures. + mkdir -p var/media + cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/object + cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/object-other + cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/nginx + $(BIN_DIR)/demo loaddata demo.json + +runserver: demo $(BIN_DIR)/demo runserver diff --git a/demo/README b/demo/README index a826a1f..73d11cc 100644 --- a/demo/README +++ b/demo/README @@ -2,8 +2,20 @@ Demo project ############ -The :file:`demo/` folder holds a demo project to illustrate django-downloadview -usage. +`Demo folder in project's repository`_ contains a Django project to illustrate +`django-downloadview` usage. + + +***************************************** +Documentation includes code from the demo +***************************************** + +Almost every example in this documentation comes from the demo: + +* discover examples in the documentation; +* browse related code and tests in demo project. + +Examples in documentation are tested via demo project! *********************** @@ -19,7 +31,7 @@ Deploy the demo System requirements: -* `Python`_ version 2.6 or 2.7, available as ``python`` command. +* `Python`_ version 2.7, available as ``python`` command. .. note:: @@ -34,7 +46,7 @@ Execute: git clone git@github.com:benoitbryon/django-downloadview.git cd django-downloadview/ - make demo + make runserver It installs and runs the demo server on localhost, port 8000. So have a look at http://localhost:8000/ @@ -47,44 +59,6 @@ at http://localhost:8000/ Browse and use :file:`demo/demoproject/` as a sandbox. -********************************* -Base example provided in the demo -********************************* - -In the "demoproject" project, there is an application called "download". - -:file:`demo/demoproject/settings.py`: - -.. literalinclude:: ../demo/demoproject/settings.py - :language: python - :lines: 33-49 - :emphasize-lines: 44 - -This application holds a ``Document`` model. - -:file:`demo/demoproject/download/models.py`: - -.. literalinclude:: ../demo/demoproject/download/models.py - :language: python - -.. note:: - - The ``storage`` is the default one, i.e. it uses ``settings.MEDIA_ROOT``. - Combined to this ``upload_to`` configuration, files for ``Document`` model - live in :file:`var/media/document/` folder, relative to your - django-downloadview clone root. - -There is a download view named "download_document" for this model: - -:file:`demo/demoproject/download/urls.py`: - -.. literalinclude:: ../demo/demoproject/download/urls.py - :language: python - -As is, Django is to serve the files, i.e. load chunks into memory and stream -them. - - ********** References ********** diff --git a/demo/demoproject/download/__init__.py b/demo/demoproject/download/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/demo/demoproject/download/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/demo/demoproject/download/models.py b/demo/demoproject/download/models.py deleted file mode 100644 index 0ff248a..0000000 --- a/demo/demoproject/download/models.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.db import models - - -class Document(models.Model): - """A sample model with a FileField.""" - slug = models.SlugField(verbose_name='slug') - file = models.FileField(verbose_name='file', upload_to='document') diff --git a/demo/demoproject/download/tests.py b/demo/demoproject/download/tests.py deleted file mode 100644 index 2338599..0000000 --- a/demo/demoproject/download/tests.py +++ /dev/null @@ -1,107 +0,0 @@ -# coding=utf8 -"""Test suite for demoproject.download.""" -from os import listdir -from os.path import abspath, dirname, join - -from django.core.files import File -from django.core.urlresolvers import reverse -from django.test import TestCase - -from django_downloadview.test import temporary_media_root - -from demoproject.download.models import Document - - -app_dir = dirname(abspath(__file__)) -fixtures_dir = join(app_dir, 'fixtures') - - -class DownloadTestCase(TestCase): - """Base class for download tests.""" - def setUp(self): - """Common setup.""" - super(DownloadTestCase, self).setUp() - self.files = {} - for f in listdir(fixtures_dir): - self.files[f] = abspath(join(fixtures_dir, f)) - - def assertDownloadHelloWorld(self, response, is_attachment=True): - """Assert response is 'hello-world.txt' download.""" - self.assertEquals(response.status_code, 200) - self.assertEquals(response['Content-Type'], - 'text/plain; charset=utf-8') - self.assertFalse('ContentEncoding' in response) - if is_attachment: - self.assertEquals(response['Content-Disposition'], - 'attachment; filename=hello-world.txt') - else: - self.assertFalse('Content-Disposition' in response) - self.assertEqual(open(self.files['hello-world.txt']).read(), - ''.join(response.streaming_content)) - - -class PathDownloadViewTestCase(DownloadTestCase): - """Test "hello_world" and "hello_world_inline" views.""" - def test_download_hello_world(self): - """hello_world view returns hello-world.txt as attachement.""" - download_url = reverse('hello_world') - response = self.client.get(download_url) - self.assertDownloadHelloWorld(response) - - def test_download_hello_world_inline(self): - """hello_world view returns hello-world.txt as attachement.""" - download_url = reverse('hello_world_inline') - response = self.client.get(download_url) - self.assertDownloadHelloWorld(response, is_attachment=False) - - -class CustomPathDownloadViewTestCase(DownloadTestCase): - """Test "fixture_from_path" view.""" - def test_download_hello_world(self): - """fixture_from_path view returns hello-world.txt as attachement.""" - download_url = reverse('fixture_from_path', args=['hello-world.txt']) - response = self.client.get(download_url) - self.assertDownloadHelloWorld(response) - - -class StorageDownloadViewTestCase(DownloadTestCase): - """Test "fixture_from_storage" view.""" - def test_download_hello_world(self): - """fixture_from_storage view returns hello-world.txt as attachement.""" - download_url = reverse('fixture_from_storage', - args=['hello-world.txt']) - response = self.client.get(download_url) - self.assertDownloadHelloWorld(response) - - -class ObjectDownloadViewTestCase(DownloadTestCase): - """Test generic ObjectDownloadView.""" - @temporary_media_root() - def test_download_hello_world(self): - """'download_document' view returns hello-world.txt as attachement.""" - slug = 'hello-world' - download_url = reverse('document', kwargs={'slug': slug}) - Document.objects.create(slug=slug, - file=File(open(self.files['hello-world.txt']))) - response = self.client.get(download_url) - self.assertDownloadHelloWorld(response) - - -class GeneratedDownloadViewTestCase(DownloadTestCase): - """Test "generated_hello_world" view.""" - def test_download_hello_world(self): - """generated_hello_world view returns hello-world.txt as attachement. - - """ - download_url = reverse('generated_hello_world') - response = self.client.get(download_url) - self.assertDownloadHelloWorld(response) - - -class ProxiedDownloadViewTestCase(DownloadTestCase): - """Test "http_hello_world" view.""" - def test_download_readme(self): - """http_hello_world view proxies file from URL.""" - download_url = reverse('http_hello_world') - response = self.client.get(download_url) - self.assertDownloadHelloWorld(response) diff --git a/demo/demoproject/download/urls.py b/demo/demoproject/download/urls.py deleted file mode 100644 index 90390ee..0000000 --- a/demo/demoproject/download/urls.py +++ /dev/null @@ -1,34 +0,0 @@ -# coding=utf8 -"""URL mapping.""" -from django.conf.urls import patterns, url - - -urlpatterns = patterns( - 'demoproject.download.views', - # Model-based downloads. - url(r'^document/(?P[a-zA-Z0-9_-]+)/$', - 'download_document', - name='document'), - # Storage-based downloads. - url(r'^storage/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', - 'download_fixture_from_storage', - name='fixture_from_storage'), - # Path-based downloads. - url(r'^hello-world\.txt$', - 'download_hello_world', - name='hello_world'), - url(r'^hello-world-inline\.txt$', - 'download_hello_world_inline', - name='hello_world_inline'), - url(r'^path/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', - 'download_fixture_from_path', - name='fixture_from_path'), - # URL-based downloads. - url(r'^http/readme\.txt$', - 'download_http_hello_world', - name='http_hello_world'), - # Generated downloads. - url(r'^generated/hello-world\.txt$', - 'download_generated_hello_world', - name='generated_hello_world'), -) diff --git a/demo/demoproject/download/views.py b/demo/demoproject/download/views.py deleted file mode 100644 index 94b8caf..0000000 --- a/demo/demoproject/download/views.py +++ /dev/null @@ -1,90 +0,0 @@ -# coding=utf8 -"""Demo download views.""" -from cStringIO import StringIO -from os.path import abspath, dirname, join - -from django.core.files.storage import FileSystemStorage - -from django_downloadview.files import VirtualFile -from django_downloadview import views -from demoproject.download.models import Document - - -# Some initializations. - -#: Directory containing code of :py:module:`demoproject.download.views`. -app_dir = dirname(abspath(__file__)) - -#: Directory containing files fixtures. -fixtures_dir = join(app_dir, 'fixtures') - -#: Path to a text file that says 'Hello world!'. -hello_world_path = join(fixtures_dir, 'hello-world.txt') - -#: Storage for fixtures. -fixtures_storage = FileSystemStorage(location=fixtures_dir) - - -# Here are the views. - -#: Pre-configured download view for :py:class:`Document` model. -download_document = views.ObjectDownloadView.as_view(model=Document) - - -#: Same as download_document, but streamed inline, i.e. not as attachments. -download_document_inline = views.ObjectDownloadView.as_view(model=Document, - attachment=False) - - -#: Pre-configured view using a storage. -download_fixture_from_storage = views.StorageDownloadView.as_view( - storage=fixtures_storage) - - -#: Direct download of one file, based on an absolute path. -#: -#: You could use this example as a shortcut, inside other views. -download_hello_world = views.PathDownloadView.as_view(path=hello_world_path) - - -#: Direct download of one file, based on an absolute path, not as attachment. -download_hello_world_inline = views.PathDownloadView.as_view( - path=hello_world_path, - attachment=False) - - -class CustomPathDownloadView(views.PathDownloadView): - """Example of customized PathDownloadView.""" - def get_path(self): - """Convert relative path (provided in URL) into absolute path. - - Notice that this particularly simple use case is covered by - :py:class:`django_downloadview.views.StorageDownloadView`. - - .. warning:: - - If you are doing such things, make the path secure! Prevent users - to download files anywhere in the filesystem. - - """ - path = super(CustomPathDownloadView, self).get_path() - return join(fixtures_dir, path) - -#: Pre-configured :py:class:`CustomPathDownloadView`. -download_fixture_from_path = CustomPathDownloadView.as_view() - - -class StringIODownloadView(views.VirtualDownloadView): - """Sample download view using StringIO object.""" - def get_file(self): - """Return wrapper on StringIO object.""" - file_obj = StringIO(u"Hello world!\n".encode('utf-8')) - return VirtualFile(file_obj, name='hello-world.txt') - -#: Pre-configured view that serves "Hello world!" via a StringIO. -download_generated_hello_world = StringIODownloadView.as_view() - - -download_http_hello_world = views.HTTPDownloadView.as_view( - url=u'http://localhost:8000/download/hello-world.txt', - basename=u'hello-world.txt') diff --git a/demo/demoproject/download/fixtures/demo.json b/demo/demoproject/fixtures/demo.json similarity index 79% rename from demo/demoproject/download/fixtures/demo.json rename to demo/demoproject/fixtures/demo.json index 43fc8fd..ec4e68d 100644 --- a/demo/demoproject/download/fixtures/demo.json +++ b/demo/demoproject/fixtures/demo.json @@ -1,7 +1,7 @@ [ { "pk": 1, - "model": "download.document", + "model": "object.document", "fields": { "slug": "hello-world", "file": "document/hello-world.txt" diff --git a/demo/demoproject/download/fixtures/hello-world.txt b/demo/demoproject/fixtures/hello-world.txt similarity index 100% rename from demo/demoproject/download/fixtures/hello-world.txt rename to demo/demoproject/fixtures/hello-world.txt diff --git a/demo/demoproject/http/__init__.py b/demo/demoproject/http/__init__.py new file mode 100644 index 0000000..dfa7a24 --- /dev/null +++ b/demo/demoproject/http/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +"""Demo for :class:`django_downloadview.HTTPDownloadView`. + +Code in this package is included in documentation's :doc:`/views/http`. +Make sure to maintain both together. + +""" diff --git a/demo/demoproject/http/models.py b/demo/demoproject/http/models.py new file mode 100644 index 0000000..35f7cd9 --- /dev/null +++ b/demo/demoproject/http/models.py @@ -0,0 +1 @@ +"""Required to make a Django application.""" diff --git a/demo/demoproject/http/tests.py b/demo/demoproject/http/tests.py new file mode 100644 index 0000000..48186c5 --- /dev/null +++ b/demo/demoproject/http/tests.py @@ -0,0 +1,16 @@ +from django.core.urlresolvers import reverse +import django.test + +from django_downloadview import assert_download_response + + +class SimpleURLTestCase(django.test.TestCase): + def test_download_response(self): + """'simple_url' serves 'hello-world.txt' from Github.""" + url = reverse('http:simple_url') + response = self.client.get(url) + assert_download_response(self, + response, + content='Hello world!\n', + basename='hello-world.txt', + mime_type='text/plain') diff --git a/demo/demoproject/http/urls.py b/demo/demoproject/http/urls.py new file mode 100644 index 0000000..11ca73d --- /dev/null +++ b/demo/demoproject/http/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls import patterns, url + +from demoproject.http import views + + +urlpatterns = patterns( + '', + url(r'^simple_url/$', + views.simple_url, + name='simple_url'), +) diff --git a/demo/demoproject/http/views.py b/demo/demoproject/http/views.py new file mode 100644 index 0000000..f35b705 --- /dev/null +++ b/demo/demoproject/http/views.py @@ -0,0 +1,12 @@ +from django_downloadview import HTTPDownloadView + + +class SimpleURLDownloadView(HTTPDownloadView): + def get_url(self): + """Return URL of hello-world.txt file on GitHub.""" + return 'https://raw.github.com/benoitbryon/django-downloadview' \ + '/b7f660c5e3f37d918b106b02c5af7a887acc0111' \ + '/demo/demoproject/download/fixtures/hello-world.txt' + + +simple_url = SimpleURLDownloadView.as_view() diff --git a/demo/demoproject/nginx/__init__.py b/demo/demoproject/nginx/__init__.py index 53fd31e..188d4e5 100644 --- a/demo/demoproject/nginx/__init__.py +++ b/demo/demoproject/nginx/__init__.py @@ -1 +1 @@ -"""Nginx optimizations applied to demoproject.download.""" +"""Nginx optimizations.""" diff --git a/demo/demoproject/nginx/models.py b/demo/demoproject/nginx/models.py index e69de29..35f7cd9 100644 --- a/demo/demoproject/nginx/models.py +++ b/demo/demoproject/nginx/models.py @@ -0,0 +1 @@ +"""Required to make a Django application.""" diff --git a/demo/demoproject/nginx/tests.py b/demo/demoproject/nginx/tests.py index 10258da..d3c7930 100644 --- a/demo/demoproject/nginx/tests.py +++ b/demo/demoproject/nginx/tests.py @@ -1,62 +1,51 @@ -"""Test suite for demoproject.nginx.""" -from django.core.files import File -from django.core.urlresolvers import reverse_lazy as reverse +import os + +from django.core.files.base import ContentFile +from django.core.urlresolvers import reverse +import django.test from django_downloadview.nginx import assert_x_accel_redirect -from django_downloadview.test import temporary_media_root -from demoproject.download.models import Document -from demoproject.download.tests import DownloadTestCase +from demoproject.nginx.views import storage, storage_dir -class XAccelRedirectDecoratorTestCase(DownloadTestCase): - @temporary_media_root() +def setup_file(): + if not os.path.exists(storage_dir): + os.makedirs(storage_dir) + storage.save('hello-world.txt', ContentFile(u'Hello world!\n')) + + +class OptimizedByMiddlewareTestCase(django.test.TestCase): def test_response(self): - """'download_document_nginx' view returns a valid X-Accel response.""" - document = Document.objects.create( - slug='hello-world', - file=File(open(self.files['hello-world.txt'])), - ) - download_url = reverse('download_document_nginx', - kwargs={'slug': 'hello-world'}) - response = self.client.get(download_url) - self.assertEquals(response.status_code, 200) - # Validation shortcut: assert_x_accel_redirect. + """'nginx:optimized_by_middleware' returns X-Accel response.""" + setup_file() + url = reverse('nginx:optimized_by_middleware') + response = self.client.get(url) assert_x_accel_redirect( self, response, content_type="text/plain; charset=utf-8", charset="utf-8", basename="hello-world.txt", - redirect_url="/download-optimized/document/hello-world.txt", + redirect_url="/nginx-optimized-by-middleware/hello-world.txt", expires=None, with_buffering=None, limit_rate=None) - # Check some more items, because this test is part of - # django-downloadview tests. - self.assertFalse('ContentEncoding' in response) - self.assertEquals(response['Content-Disposition'], - 'attachment; filename=hello-world.txt') -class InlineXAccelRedirectTestCase(DownloadTestCase): - @temporary_media_root() +class OptimizedByDecoratorTestCase(django.test.TestCase): def test_response(self): - """X-Accel optimization respects ``attachment`` attribute.""" - document = Document.objects.create( - slug='hello-world', - file=File(open(self.files['hello-world.txt'])), - ) - download_url = reverse('download_document_nginx_inline', - kwargs={'slug': 'hello-world'}) - response = self.client.get(download_url) + """'nginx:optimized_by_decorator' returns X-Accel response.""" + setup_file() + url = reverse('nginx:optimized_by_decorator') + response = self.client.get(url) assert_x_accel_redirect( self, response, content_type="text/plain; charset=utf-8", charset="utf-8", - attachment=False, - redirect_url="/download-optimized/document/hello-world.txt", + basename="hello-world.txt", + redirect_url="/nginx-optimized-by-decorator/hello-world.txt", expires=None, with_buffering=None, limit_rate=None) diff --git a/demo/demoproject/nginx/urls.py b/demo/demoproject/nginx/urls.py index 9920da8..b7817a5 100644 --- a/demo/demoproject/nginx/urls.py +++ b/demo/demoproject/nginx/urls.py @@ -4,9 +4,10 @@ from django.conf.urls import patterns, url urlpatterns = patterns( 'demoproject.nginx.views', - url(r'^document-nginx/(?P[a-zA-Z0-9_-]+)/$', - 'download_document_nginx', name='download_document_nginx'), - url(r'^document-nginx-inline/(?P[a-zA-Z0-9_-]+)/$', - 'download_document_nginx_inline', - name='download_document_nginx_inline'), + url(r'^optimized-by-middleware/$', + 'optimized_by_middleware', + name='optimized_by_middleware'), + url(r'^optimized-by-decorator/$', + 'optimized_by_decorator', + name='optimized_by_decorator'), ) diff --git a/demo/demoproject/nginx/views.py b/demo/demoproject/nginx/views.py index 34d4bbd..e8a44c9 100644 --- a/demo/demoproject/nginx/views.py +++ b/demo/demoproject/nginx/views.py @@ -1,18 +1,22 @@ -"""Views.""" -from django.conf import settings +import os +from django.conf import settings +from django.core.files.storage import FileSystemStorage + +from django_downloadview import StorageDownloadView from django_downloadview.nginx import x_accel_redirect -from demoproject.download import views + +storage_dir = os.path.join(settings.MEDIA_ROOT, 'nginx') +storage = FileSystemStorage(location=storage_dir, + base_url=''.join([settings.MEDIA_URL, 'nginx/'])) -download_document_nginx = x_accel_redirect( - views.download_document, - source_dir='/var/www/files', - destination_url='/download-optimized') +optimized_by_middleware = StorageDownloadView.as_view(storage=storage, + path='hello-world.txt') -download_document_nginx_inline = x_accel_redirect( - views.download_document_inline, - source_dir=settings.MEDIA_ROOT, - destination_url='/download-optimized') +optimized_by_decorator = x_accel_redirect( + StorageDownloadView.as_view(storage=storage, path='hello-world.txt'), + source_url=storage.base_url, + destination_url='/nginx-optimized-by-decorator/') diff --git a/demo/demoproject/object/__init__.py b/demo/demoproject/object/__init__.py new file mode 100644 index 0000000..9c90b24 --- /dev/null +++ b/demo/demoproject/object/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +"""Demo for :class:`django_downloadview.ObjectDownloadView`. + +Code in this package is included in documentation's :doc:`/views/object`. +Make sure to maintain both together. + +""" diff --git a/demo/demoproject/object/models.py b/demo/demoproject/object/models.py new file mode 100644 index 0000000..bd55215 --- /dev/null +++ b/demo/demoproject/object/models.py @@ -0,0 +1,8 @@ +from django.db import models + + +class Document(models.Model): + slug = models.SlugField() + file = models.FileField(upload_to='object') + another_file = models.FileField(upload_to='object-other') + basename = models.CharField(max_length=100) diff --git a/demo/demoproject/object/tests.py b/demo/demoproject/object/tests.py new file mode 100644 index 0000000..4b6b537 --- /dev/null +++ b/demo/demoproject/object/tests.py @@ -0,0 +1,70 @@ +from django.core.files.base import ContentFile +from django.core.urlresolvers import reverse +import django.test + +from django_downloadview import temporary_media_root, assert_download_response + +from demoproject.object.models import Document + + +# Fixtures. +slug = 'hello-world' +basename = 'hello-world.txt' +file_name = 'file.txt' +another_name = 'another_file.txt' +file_content = 'Hello world!\n' +another_content = 'Goodbye world!\n' + + +def setup_document(): + document = Document(slug=slug, basename=basename) + document.file.save(file_name, + ContentFile(file_content), + save=False) + document.another_file.save(another_name, + ContentFile(another_content), + save=False) + document.save() + return document + + +class DefaultFileTestCase(django.test.TestCase): + @temporary_media_root() + def test_download_response(self): + """'default_file' streams Document.file.""" + setup_document() + url = reverse('object:default_file', kwargs={'slug': slug}) + response = self.client.get(url) + assert_download_response(self, + response, + content=file_content, + basename=file_name, + mime_type='text/plain') + + +class AnotherFileTestCase(django.test.TestCase): + @temporary_media_root() + def test_download_response(self): + """'another_file' streams Document.another_file.""" + setup_document() + url = reverse('object:another_file', kwargs={'slug': slug}) + response = self.client.get(url) + assert_download_response(self, + response, + content=another_content, + basename=another_name, + mime_type='text/plain') + + +class DeserializedBasenameTestCase(django.test.TestCase): + @temporary_media_root() + def test_download_response(self): + "'deserialized_basename' streams Document.file with custom basename." + setup_document() + url = reverse('object:deserialized_basename', kwargs={'slug': slug}) + response = self.client.get(url) + assert_download_response(self, + response, + content=file_content, + basename=basename, + mime_type='text/plain') diff --git a/demo/demoproject/object/urls.py b/demo/demoproject/object/urls.py new file mode 100644 index 0000000..1e16d08 --- /dev/null +++ b/demo/demoproject/object/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls import patterns, url + +from demoproject.object import views + + +urlpatterns = patterns( + '', + url(r'^default-file/(?P[a-zA-Z0-9_-]+)/$', + views.default_file_view, + name='default_file'), + url(r'^another-file/(?P[a-zA-Z0-9_-]+)/$', + views.another_file_view, + name='another_file'), + url(r'^deserialized_basename/(?P[a-zA-Z0-9_-]+)/$', + views.deserialized_basename_view, + name='deserialized_basename'), +) diff --git a/demo/demoproject/object/views.py b/demo/demoproject/object/views.py new file mode 100644 index 0000000..d512ed8 --- /dev/null +++ b/demo/demoproject/object/views.py @@ -0,0 +1,18 @@ +from django_downloadview import ObjectDownloadView + +from demoproject.object.models import Document + + +#: Serve ``file`` attribute of ``Document`` model. +default_file_view = ObjectDownloadView.as_view(model=Document) + +#: Serve ``another_file`` attribute of ``Document`` model. +another_file_view = ObjectDownloadView.as_view( + model=Document, + file_field='another_file') + +#: Serve ``file`` attribute of ``Document`` model, using client-side filename +#: from model. +deserialized_basename_view = ObjectDownloadView.as_view( + model=Document, + basename_field='basename') diff --git a/demo/demoproject/path/__init__.py b/demo/demoproject/path/__init__.py new file mode 100644 index 0000000..2ce1464 --- /dev/null +++ b/demo/demoproject/path/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +"""Demo for :class:`django_downloadview.PathDownloadView`. + +Code in this package is included in documentation's :doc:`/views/path`. +Make sure to maintain both together. + +""" diff --git a/demo/demoproject/path/models.py b/demo/demoproject/path/models.py new file mode 100644 index 0000000..35f7cd9 --- /dev/null +++ b/demo/demoproject/path/models.py @@ -0,0 +1 @@ +"""Required to make a Django application.""" diff --git a/demo/demoproject/path/tests.py b/demo/demoproject/path/tests.py new file mode 100644 index 0000000..facef1b --- /dev/null +++ b/demo/demoproject/path/tests.py @@ -0,0 +1,28 @@ +from django.core.urlresolvers import reverse +import django.test + +from django_downloadview import assert_download_response + + +class StaticPathTestCase(django.test.TestCase): + def test_download_response(self): + """'static_path' serves 'fixtures/hello-world.txt'.""" + url = reverse('path:static_path') + response = self.client.get(url) + assert_download_response(self, + response, + content='Hello world!\n', + basename='hello-world.txt', + mime_type='text/plain') + + +class DynamicPathTestCase(django.test.TestCase): + def test_download_response(self): + """'dynamic_path' serves 'fixtures/{path}'.""" + url = reverse('path:dynamic_path', kwargs={'path': 'hello-world.txt'}) + response = self.client.get(url) + assert_download_response(self, + response, + content='Hello world!\n', + basename='hello-world.txt', + mime_type='text/plain') diff --git a/demo/demoproject/path/urls.py b/demo/demoproject/path/urls.py new file mode 100644 index 0000000..18f2847 --- /dev/null +++ b/demo/demoproject/path/urls.py @@ -0,0 +1,14 @@ +from django.conf.urls import patterns, url + +from demoproject.path import views + + +urlpatterns = patterns( + '', + url(r'^static-path/$', + views.static_path, + name='static_path'), + url(r'^dynamic-path/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', + views.dynamic_path, + name='dynamic_path'), +) diff --git a/demo/demoproject/path/views.py b/demo/demoproject/path/views.py new file mode 100644 index 0000000..66b438b --- /dev/null +++ b/demo/demoproject/path/views.py @@ -0,0 +1,39 @@ +import os + +from django_downloadview import PathDownloadView + + +# Let's initialize some fixtures. +app_dir = os.path.dirname(os.path.abspath(__file__)) +project_dir = os.path.dirname(app_dir) +fixtures_dir = os.path.join(project_dir, 'fixtures') +#: Path to a text file that says 'Hello world!'. +hello_world_path = os.path.join(fixtures_dir, 'hello-world.txt') + +#: Serve ``fixtures/hello-world.txt`` file. +static_path = PathDownloadView.as_view(path=hello_world_path) + + +class DynamicPathDownloadView(PathDownloadView): + """Serve file in ``settings.MEDIA_ROOT``. + + .. warning:: + + Make sure to prevent "../" in path via URL patterns. + + .. note:: + + This particular setup would be easier to perform with + :class:`StorageDownloadView` + + """ + def get_path(self): + """Return path inside fixtures directory.""" + # Get path from URL resolvers or as_view kwarg. + relative_path = super(DynamicPathDownloadView, self).get_path() + # Make it absolute. + absolute_path = os.path.join(fixtures_dir, relative_path) + return absolute_path + + +dynamic_path = DynamicPathDownloadView.as_view() diff --git a/demo/demoproject/settings.py b/demo/demoproject/settings.py index f647ed1..56742e1 100755 --- a/demo/demoproject/settings.py +++ b/demo/demoproject/settings.py @@ -45,30 +45,34 @@ INSTALLED_APPS = ( 'django.contrib.staticfiles', # The actual django-downloadview demo. 'demoproject', - 'demoproject.download', # Sample standard download views. - 'demoproject.nginx', # Sample optimizations for Nginx. + 'demoproject.object', # Demo around ObjectDownloadView + 'demoproject.storage', # Demo around StorageDownloadView + 'demoproject.path', # Demo around PathDownloadView + 'demoproject.http', # Demo around HTTPDownloadView + 'demoproject.virtual', # Demo around VirtualDownloadView + 'demoproject.nginx', # Sample optimizations for Nginx X-Accel. # For test purposes. The demo project is part of django-downloadview # test suite. 'django_nose', ) -# Default middlewares. You may alter the list later. +# Middlewares. MIDDLEWARE_CLASSES = [ 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', + 'django_downloadview.DownloadDispatcherMiddleware' ] # Uncomment the following lines to enable global Nginx optimizations. -#MIDDLEWARE_CLASSES.append('django_downloadview.DownloadDispatcherMiddleware') DOWNLOADVIEW_MIDDLEWARES = ( ('default', 'django_downloadview.nginx.XAccelRedirectMiddleware', - {'source_dir': MEDIA_ROOT, - 'destination_url': '/proxied-download'}), + {'source_url': '/media/nginx/', + 'destination_url': '/nginx-optimized-by-middleware/'}), ) diff --git a/demo/demoproject/storage/__init__.py b/demo/demoproject/storage/__init__.py new file mode 100644 index 0000000..8485df0 --- /dev/null +++ b/demo/demoproject/storage/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +"""Demo for :class:`django_downloadview.StorageDownloadView`. + +Code in this package is included in documentation's :doc:`/views/storage`. +Make sure to maintain both together. + +""" diff --git a/demo/demoproject/storage/models.py b/demo/demoproject/storage/models.py new file mode 100644 index 0000000..35f7cd9 --- /dev/null +++ b/demo/demoproject/storage/models.py @@ -0,0 +1 @@ +"""Required to make a Django application.""" diff --git a/demo/demoproject/storage/storage.py b/demo/demoproject/storage/storage.py new file mode 100644 index 0000000..f685df2 --- /dev/null +++ b/demo/demoproject/storage/storage.py @@ -0,0 +1,4 @@ +from django.core.files.storage import FileSystemStorage + + +storage = FileSystemStorage() diff --git a/demo/demoproject/storage/tests.py b/demo/demoproject/storage/tests.py new file mode 100644 index 0000000..4bcb064 --- /dev/null +++ b/demo/demoproject/storage/tests.py @@ -0,0 +1,48 @@ +try: + from unittest import mock +except ImportError: # Python 2.x fallback. + import mock + +from django.core.files.base import ContentFile +from django.core.urlresolvers import reverse +import django.test + +from django_downloadview import temporary_media_root, assert_download_response + +from demoproject.storage.views import storage + + +# Fixtures. +file_content = 'Hello world!\n' + + +def setup_file(path): + storage.save(path, ContentFile(file_content)) + + +class StaticPathTestCase(django.test.TestCase): + @temporary_media_root() + def test_download_response(self): + """'static_path' streams file by path.""" + setup_file('1.txt') + url = reverse('storage:static_path', kwargs={'path': '1.txt'}) + response = self.client.get(url) + assert_download_response(self, + response, + content=file_content, + basename='1.txt', + mime_type='text/plain') + + +class DynamicPathTestCase(django.test.TestCase): + @temporary_media_root() + def test_download_response(self): + """'dynamic_path' streams file by generated path.""" + setup_file('1.TXT') + url = reverse('storage:dynamic_path', kwargs={'path': '1.txt'}) + response = self.client.get(url) + assert_download_response(self, + response, + content=file_content, + basename='1.TXT', + mime_type='text/plain') diff --git a/demo/demoproject/storage/urls.py b/demo/demoproject/storage/urls.py new file mode 100644 index 0000000..b3f912e --- /dev/null +++ b/demo/demoproject/storage/urls.py @@ -0,0 +1,14 @@ +from django.conf.urls import patterns, url + +from demoproject.storage import views + + +urlpatterns = patterns( + '', + url(r'^static-path/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', + views.static_path, + name='static_path'), + url(r'^dynamic-path/(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', + views.dynamic_path, + name='dynamic_path'), +) diff --git a/demo/demoproject/storage/views.py b/demo/demoproject/storage/views.py new file mode 100644 index 0000000..51e7acc --- /dev/null +++ b/demo/demoproject/storage/views.py @@ -0,0 +1,20 @@ +from django.core.files.storage import FileSystemStorage + +from django_downloadview import StorageDownloadView + + +storage = FileSystemStorage() + + +#: Serve file using ``path`` argument. +static_path = StorageDownloadView.as_view(storage=storage) + + +class DynamicStorageDownloadView(StorageDownloadView): + """Serve file of storage by path.upper().""" + def get_path(self): + """Return uppercase path.""" + return super(DynamicStorageDownloadView, self).get_path().upper() + + +dynamic_path = DynamicStorageDownloadView.as_view(storage=storage) diff --git a/demo/demoproject/templates/home.html b/demo/demoproject/templates/home.html index a7293bd..f3d9588 100644 --- a/demo/demoproject/templates/home.html +++ b/demo/demoproject/templates/home.html @@ -10,19 +10,7 @@

Serving files with Django

In the following views, Django streams the files, no optimization has been setup.

-
    -
  • PathDownloadView
  • -
  • - PathDownloadView + argument in URL -
  • -
  • - StorageDownloadView + path in URL -
  • -
  • - ObjectDownloadView -
  • -
  • - HTTPDownloadView, a simple HTTP proxy
  • +

    Optimized downloads

    @@ -31,9 +19,6 @@

    Since nginx and other servers aren't installed on the demo, you will get raw "X-Sendfile" responses. Look at the headers!

    diff --git a/demo/demoproject/urls.py b/demo/demoproject/urls.py index d8ffdd0..80ffcab 100755 --- a/demo/demoproject/urls.py +++ b/demo/demoproject/urls.py @@ -7,10 +7,30 @@ home = TemplateView.as_view(template_name='home.html') urlpatterns = patterns( '', - # Standard download views. - url(r'^download/', include('demoproject.download.urls')), + # ObjectDownloadView. + url(r'^object/', include('demoproject.object.urls', + app_name='object', + namespace='object')), + # StorageDownloadView. + url(r'^storage/', include('demoproject.storage.urls', + app_name='storage', + namespace='storage')), + # PathDownloadView. + url(r'^path/', include('demoproject.path.urls', + app_name='path', + namespace='path')), + # HTTPDownloadView. + url(r'^http/', include('demoproject.http.urls', + app_name='http', + namespace='http')), + # VirtualDownloadView. + url(r'^virtual/', include('demoproject.virtual.urls', + app_name='virtual', + namespace='virtual')), # Nginx optimizations. - url(r'^nginx/', include('demoproject.nginx.urls')), + url(r'^nginx/', include('demoproject.nginx.urls', + app_name='nginx', + namespace='nginx')), # An informative homepage. url(r'', home, name='home') ) diff --git a/demo/demoproject/virtual/__init__.py b/demo/demoproject/virtual/__init__.py new file mode 100644 index 0000000..30de8d6 --- /dev/null +++ b/demo/demoproject/virtual/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +"""Demo for :class:`django_downloadview.VirtualDownloadView`. + +Code in this package is included in documentation's :doc:`/views/virtual`. +Make sure to maintain both together. + +""" diff --git a/demo/demoproject/virtual/models.py b/demo/demoproject/virtual/models.py new file mode 100644 index 0000000..35f7cd9 --- /dev/null +++ b/demo/demoproject/virtual/models.py @@ -0,0 +1 @@ +"""Required to make a Django application.""" diff --git a/demo/demoproject/virtual/tests.py b/demo/demoproject/virtual/tests.py new file mode 100644 index 0000000..a8b92b0 --- /dev/null +++ b/demo/demoproject/virtual/tests.py @@ -0,0 +1,40 @@ +from django.core.urlresolvers import reverse +import django.test + +from django_downloadview import assert_download_response + + +class TextTestCase(django.test.TestCase): + def test_download_response(self): + """'virtual:text' serves 'hello-world.txt' from unicode.""" + url = reverse('virtual:text') + response = self.client.get(url) + assert_download_response(self, + response, + content='Hello world!\n', + basename='hello-world.txt', + mime_type='text/plain') + + +class StringIOTestCase(django.test.TestCase): + def test_download_response(self): + """'virtual:stringio' serves 'hello-world.txt' from stringio.""" + url = reverse('virtual:stringio') + response = self.client.get(url) + assert_download_response(self, + response, + content='Hello world!\n', + basename='hello-world.txt', + mime_type='text/plain') + + +class GeneratedTestCase(django.test.TestCase): + def test_download_response(self): + """'virtual:generated' serves 'hello-world.txt' from generator.""" + url = reverse('virtual:generated') + response = self.client.get(url) + assert_download_response(self, + response, + content='Hello world!\n', + basename='hello-world.txt', + mime_type='text/plain') diff --git a/demo/demoproject/virtual/urls.py b/demo/demoproject/virtual/urls.py new file mode 100644 index 0000000..6c6a7ce --- /dev/null +++ b/demo/demoproject/virtual/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls import patterns, url + +from demoproject.virtual import views + + +urlpatterns = patterns( + '', + url(r'^text/$', + views.TextDownloadView.as_view(), + name='text'), + url(r'^stringio/$', + views.StringIODownloadView.as_view(), + name='stringio'), + url(r'^gerenated/$', + views.GeneratedDownloadView.as_view(), + name='generated'), +) diff --git a/demo/demoproject/virtual/views.py b/demo/demoproject/virtual/views.py new file mode 100644 index 0000000..9b4461c --- /dev/null +++ b/demo/demoproject/virtual/views.py @@ -0,0 +1,33 @@ +from StringIO import StringIO + +from django.core.files.base import ContentFile + +from django_downloadview import VirtualDownloadView +from django_downloadview import VirtualFile +from django_downloadview import StringIteratorIO + + +class TextDownloadView(VirtualDownloadView): + def get_file(self): + """Return :class:`django.core.files.base.ContentFile` object.""" + return ContentFile(u"Hello world!\n", name='hello-world.txt') + + +class StringIODownloadView(VirtualDownloadView): + def get_file(self): + """Return wrapper on ``StringIO`` object.""" + file_obj = StringIO(u"Hello world!\n".encode('utf-8')) + return VirtualFile(file_obj, name='hello-world.txt') + + +def generate_hello(): + yield u'Hello ' + yield u'world!' + yield u'\n' + + +class GeneratedDownloadView(VirtualDownloadView): + def get_file(self): + """Return wrapper on ``StringIteratorIO`` object.""" + file_obj = StringIteratorIO(generate_hello()) + return VirtualFile(file_obj, name='hello-world.txt') diff --git a/django_downloadview/api.py b/django_downloadview/api.py index 7178c1e..a51f821 100644 --- a/django_downloadview/api.py +++ b/django_downloadview/api.py @@ -3,7 +3,8 @@ from django_downloadview.io import StringIteratorIO # NoQA from django_downloadview.files import (StorageFile, # NoQA VirtualFile, - HTTPFile) + HTTPFile, + File) from django_downloadview.response import (DownloadResponse, # NoQA ProxiedDownloadResponse) from django_downloadview.middlewares import (BaseDownloadMiddleware, # NoQA @@ -12,5 +13,8 @@ from django_downloadview.nginx import XAccelRedirectMiddleware # NoQA from django_downloadview.views import (PathDownloadView, # NoQA ObjectDownloadView, StorageDownloadView, + HTTPDownloadView, VirtualDownloadView) from django_downloadview.sendfile import sendfile # NoQA +from django_downloadview.test import (assert_download_response, # NoQA + temporary_media_root) diff --git a/django_downloadview/decorators.py b/django_downloadview/decorators.py index f231870..5c070ba 100644 --- a/django_downloadview/decorators.py +++ b/django_downloadview/decorators.py @@ -1,13 +1,13 @@ """View decorators. See also decorators provided by server-specific modules, such as -:py:func:`django_downloadview.nginx.x_accel_redirect`. +:func:`django_downloadview.nginx.x_accel_redirect`. """ class DownloadDecorator(object): - """View decorator factory to apply middleware to ``view_func`` response. + """View decorator factory to apply middleware to ``view_func``'s response. Middleware instance is built from ``middleware_factory`` with ``*args`` and ``**kwargs``. Middleware factory is typically a class, such as some diff --git a/django_downloadview/files.py b/django_downloadview/files.py index e1b53c4..fb7d3a8 100644 --- a/django_downloadview/files.py +++ b/django_downloadview/files.py @@ -1,10 +1,43 @@ # -*- coding: utf-8 -*- """File wrappers for use as exchange data between views and responses.""" -from django.core.files import File +from __future__ import absolute_import +from io import BytesIO +from urlparse import urlparse + +import django.core.files +from django.utils.encoding import force_bytes import requests +class File(django.core.files.File): + """Patch Django's :meth:`__iter__` implementation. + + See https://code.djangoproject.com/ticket/21321 + + """ + def __iter__(self): + # Iterate over this file-like object by newlines + buffer_ = None + for chunk in self.chunks(): + chunk_buffer = BytesIO(force_bytes(chunk)) + + for line in chunk_buffer: + if buffer_: + line = buffer_ + line + buffer_ = None + + # If this is the end of a line, yield + # otherwise, wait for the next round + if line[-1] in ('\n', '\r'): + yield line + else: + buffer_ = line + + if buffer_ is not None: + yield buffer_ + + class StorageFile(File): """A file in a Django storage. @@ -186,7 +219,14 @@ class HTTPFile(File): **kwargs): self.request_factory = request_factory self.url = url - self.name = name + if name is None: + parts = urlparse(url) + if parts.path: # Name from path. + self.name = parts.path.strip('/').rsplit('/', 1)[-1] + else: # Name from domain. + self.name = parts.netloc + else: + self.name = name kwargs['stream'] = True self.request_kwargs = kwargs diff --git a/django_downloadview/middlewares.py b/django_downloadview/middlewares.py index 8d02503..1ad1f9b 100644 --- a/django_downloadview/middlewares.py +++ b/django_downloadview/middlewares.py @@ -102,6 +102,10 @@ class DownloadDispatcherMiddleware(BaseDownloadMiddleware): return response +class NoRedirectionMatch(Exception): + """Response object does not match redirection rules.""" + + class ProxiedDownloadMiddleware(RealDownloadMiddleware): """Base class for middlewares that use optimizations of reverse proxies.""" def __init__(self, source_dir=None, source_url=None, destination_url=None): @@ -114,7 +118,7 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware): """Return redirect URL for file wrapped into response.""" url = None file_url = '' - if self.source_url is not None: + if self.source_url: try: file_url = response.file.url except AttributeError: @@ -122,9 +126,9 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware): else: if file_url.startswith(self.source_url): file_url = file_url[len(self.source_url):] - url = file_url + url = file_url file_name = '' - if url is None and self.source_dir is not None: + if url is None and self.source_dir: try: file_name = response.file.name except AttributeError: @@ -132,7 +136,7 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware): else: if file_name.startswith(self.source_dir): file_name = os.path.relpath(file_name, self.source_dir) - url = file_name.replace(os.path.sep, '/') + url = file_name.replace(os.path.sep, '/') if url is None: message = ("""Couldn't capture/convert file attributes into a """ """redirection. """ @@ -144,5 +148,5 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware): 'file_url': file_url, 'source_dir': self.source_dir, 'file_name': file_name}) - raise Exception(message) + raise NoRedirectionMatch(message) return '/'.join((self.destination_url.rstrip('/'), url.lstrip('/'))) diff --git a/django_downloadview/nginx/decorators.py b/django_downloadview/nginx/decorators.py index 59c5941..468d727 100644 --- a/django_downloadview/nginx/decorators.py +++ b/django_downloadview/nginx/decorators.py @@ -4,9 +4,13 @@ from django_downloadview.decorators import DownloadDecorator from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware -#: Apply BaseXAccelRedirectMiddleware to ``view_func`` response. -#: -#: Proxies additional arguments (``*args``, ``**kwargs``) to -#: :py:class:`BaseXAccelRedirectMiddleware` constructor (``expires``, -#: ``with_buffering``, and ``limit_rate``). -x_accel_redirect = DownloadDecorator(XAccelRedirectMiddleware) +def x_accel_redirect(view_func, *args, **kwargs): + """Apply + :class:`~django_downloadview.nginx.middlewares.XAccelRedirectMiddleware` to + ``view_func``. + + Proxies (``*args``, ``**kwargs``) to middleware constructor. + + """ + decorator = DownloadDecorator(XAccelRedirectMiddleware) + return decorator(view_func, *args, **kwargs) diff --git a/django_downloadview/nginx/middlewares.py b/django_downloadview/nginx/middlewares.py index c6b101f..1ad2fcc 100644 --- a/django_downloadview/nginx/middlewares.py +++ b/django_downloadview/nginx/middlewares.py @@ -3,7 +3,8 @@ import warnings from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django_downloadview.middlewares import ProxiedDownloadMiddleware +from django_downloadview.middlewares import (ProxiedDownloadMiddleware, + NoRedirectionMatch) from django_downloadview.nginx.response import XAccelRedirectResponse @@ -50,7 +51,10 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware): def process_download_response(self, request, response): """Replace DownloadResponse instances by NginxDownloadResponse ones.""" - redirect_url = self.get_redirect_url(response) + try: + redirect_url = self.get_redirect_url(response) + except NoRedirectionMatch: + return response if self.expires: expires = self.expires else: diff --git a/django_downloadview/response.py b/django_downloadview/response.py index 49af040..e592d8f 100644 --- a/django_downloadview/response.py +++ b/django_downloadview/response.py @@ -8,44 +8,64 @@ from django.http import HttpResponse, StreamingHttpResponse class DownloadResponse(StreamingHttpResponse): - """File download response. + """File download response (Django serves file, client downloads it). - :py:attr:`content` attribute is supposed to be a file object wrapper, which - makes this response lazy. + This is a specialization of :class:`django.http.StreamingHttpResponse` + where :attr:`~django.http.StreamingHttpResponse.streaming_content` is a + file wrapper. - This is a specialization of :py:class:`django.http.StreamingHttpResponse`. + Constructor differs a bit from :class:`~django.http.response.HttpResponse`: + + ``file_instance`` + A :doc:`file wrapper instance `, such as + :class:`~django.core.files.base.File`. + + ``attachement`` + Boolean. Whether to return the file as attachment or not. + Affects ``Content-Disposition`` header. + + ``basename`` + Unicode. Client-side name of the file to stream. + Only used if ``attachment`` is ``True``. + Affects ``Content-Disposition`` header. + + ``status`` + HTTP status code. + + ``content_type`` + Value for ``Content-Type`` header. + If ``None``, then mime-type and encoding will be populated by the + response (default implementation uses mimetypes, based on file + name). + + + Here are some highlights to understand internal mechanisms and motivations: + + * Let's start by quoting :pep:`3333` (WSGI specification): + + For large files, or for specialized uses of HTTP streaming, + applications will usually return an iterator (often a + generator-iterator) that produces the output in a block-by-block + fashion. + + * `Django WSGI handler (application implementation) return response object + `_. + + * :class:`django.http.HttpResponse` and subclasses are iterators. + + * In :class:`~django.http.StreamingHttpResponse`, the + :meth:`~container.__iter__` implementation proxies to + :attr:`~django.http.StreamingHttpResponse.streaming_content`. + + * In :class:`DownloadResponse` and subclasses, :attr:`streaming_content` + is a :doc:`file wrapper `. File wrapper is itself an iterator + over actual file content, and it also encapsulates access to file + attributes (size, name, ...). """ def __init__(self, file_instance, attachment=True, basename=None, status=200, content_type=None): - """Constructor. - - It differs a bit from HttpResponse constructor. - - file_instance: - A file wrapper object. Could be a FieldFile. - - attachement: - Boolean, whether to return the file as attachment or not. Affects - "Content-Disposition" header. - Defaults to ``True``. - - basename: - Unicode. Only used if ``attachment`` is ``True``. Client-side name - of the file to stream. Affects "Content-Disposition" header. - Defaults to basename(``file_instance.name``). - - status: - HTTP status code. - Defaults to 200. - - content_type: - Value for "Content-Type" header. - If ``None``, then mime-type and encoding will be populated by the - response (default implementation uses mimetypes, based on file name). - Defaults is ``None``. - - """ + """Constructor.""" self.file = file_instance super(DownloadResponse, self).__init__(streaming_content=self.file, status=status, diff --git a/django_downloadview/test.py b/django_downloadview/test.py index 582512b..3b0cd2d 100644 --- a/django_downloadview/test.py +++ b/django_downloadview/test.py @@ -9,7 +9,11 @@ from django_downloadview.middlewares import is_download_response class temporary_media_root(override_settings): - """Context manager or decorator to override settings.MEDIA_ROOT. + """Temporarily override settings.MEDIA_ROOT with a temporary directory. + + The temporary directory is automatically created and destroyed. + + Use this function as a context manager: >>> from django_downloadview.test import temporary_media_root >>> from django.conf import settings @@ -20,6 +24,8 @@ class temporary_media_root(override_settings): >>> global_media_root == settings.MEDIA_ROOT True + Or as a decorator: + >>> @temporary_media_root() ... def use_temporary_media_root(): ... return settings.MEDIA_ROOT @@ -73,9 +79,10 @@ class DownloadResponseValidator(object): test_case.assertTrue(is_download_response(response)) def assert_basename(self, test_case, response, value): - test_case.assertEqual(response.basename, value) - test_case.assertTrue('filename={name}'.format(name=response.basename), - value) + """Implies ``attachement is True``.""" + test_case.assertTrue( + response['Content-Disposition'].endswith( + 'filename={name}'.format(name=value))) def assert_content_type(self, test_case, response, value): test_case.assertEqual(response['Content-Type'], value) @@ -84,7 +91,6 @@ class DownloadResponseValidator(object): test_case.assertTrue(response['Content-Type'].startswith(value)) def assert_content(self, test_case, response, value): - test_case.assertEqual(response.file.read(), value) test_case.assertEqual(''.join(response.streaming_content), value) def assert_attachment(self, test_case, response, value): @@ -93,7 +99,7 @@ class DownloadResponseValidator(object): def assert_download_response(test_case, response, **assertions): - """Make ``test_case`` assert that ``response`` is a DownloadResponse. + """Make ``test_case`` assert that ``response`` meets ``assertions``. Optional ``assertions`` dictionary can be used to check additional items: @@ -101,9 +107,12 @@ def assert_download_response(test_case, response, **assertions): * ``content_type``: the value of "Content-Type" header. - * ``charset``: the value of ``X-Accel-Charset`` header. + * ``mime_type``: the MIME type part of "Content-Type" header (without + charset). - * ``content``: the content of the file to be downloaded. + * ``content``: the contents of the file. + + * ``attachment``: whether the file is returned as attachment or not. """ validator = DownloadResponseValidator() diff --git a/django_downloadview/views/http.py b/django_downloadview/views/http.py index 207840b..c4e5d0b 100644 --- a/django_downloadview/views/http.py +++ b/django_downloadview/views/http.py @@ -15,15 +15,27 @@ class HTTPDownloadView(BaseDownloadView): request_kwargs = {} def get_request_factory(self): - """Return request factory to perform actual HTTP request.""" + """Return request factory to perform actual HTTP request. + + Default implementation returns :func:`requests.get` callable. + + """ return requests.get def get_request_kwargs(self): - """Return keyword arguments for use with request factory.""" + """Return keyword arguments for use with :meth:`get_request_factory`. + + Default implementation returns :attr:`request_kwargs`. + + """ return self.request_kwargs def get_url(self): - """Return remote file URL (the one we are proxying).""" + """Return remote file URL (the one we are proxying). + + Default implementation returns :attr:`url`. + + """ return self.url def get_file(self): diff --git a/django_downloadview/views/virtual.py b/django_downloadview/views/virtual.py index 97dd040..6f97824 100644 --- a/django_downloadview/views/virtual.py +++ b/django_downloadview/views/virtual.py @@ -6,8 +6,6 @@ from django_downloadview.views.base import BaseDownloadView class VirtualDownloadView(BaseDownloadView): """Serve not-on-disk or generated-on-the-fly file. - Use this class to serve :py:class:`StringIO` files. - Override the :py:meth:`get_file` method to customize file wrapper. """ diff --git a/docs/conf.py b/docs/conf.py index bad7e86..f02cf91 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -197,6 +197,7 @@ intersphinx_mapping = { 'python': ('http://docs.python.org/2.7', None), 'django': ('http://docs.djangoproject.com/en/1.5/', 'http://docs.djangoproject.com/en/1.5/_objects/'), + 'requests': ('http://docs.python-requests.org/en/latest/', None), } # -- Options for LaTeX output -------------------------------------------------- diff --git a/docs/dev.txt b/docs/dev.txt index 9657a73..4a8a442 100644 --- a/docs/dev.txt +++ b/docs/dev.txt @@ -44,7 +44,7 @@ Setup a development environment System requirements: -* `Python`_ version 2.6 or 2.7, available as ``python`` command. +* `Python`_ version 2.7, available as ``python`` command. .. note:: @@ -80,21 +80,6 @@ The :file:`Makefile` is intended to be a live reference for the development environment. -************* -Documentation -************* - -Follow `style guide for Sphinx-based documentations`_ when editing the -documentation. - - -************** -Test and build -************** - -Use `the Makefile`_. - - ********************* Demo project included ********************* diff --git a/docs/optimizations/index.txt b/docs/optimizations/index.txt index 34040d3..2ebbda5 100644 --- a/docs/optimizations/index.txt +++ b/docs/optimizations/index.txt @@ -11,67 +11,41 @@ proxy: As a result, you get increased performance: reverse proxies are more efficient than Django at serving static files. +The setup depends on the reverse proxy: + .. toctree:: - :maxdepth: 2 + :titlesonly: nginx -Currently, only `nginx's X-Accel`_ is supported, but `contributions are -welcome`_! +.. note:: + + Currently, only `nginx's X-Accel`_ is supported, but `contributions are + welcome`_! ***************** How does it work? ***************** -The feature is inspired by `Django's TemplateResponse`_: the download views -return some :py:class:`django_downloadview.response.DownloadResponse` instance. -Such a response does not contain file data. +View return some :class:`~django_downloadview.response.DownloadResponse` +instance, which itself carries a :doc:`file wrapper `. -By default, at the end of Django's request/response handling, Django iterates -over the ``content`` attribute of the response. In a `DownloadResponse``, -this ``content`` attribute is a file wrapper. +`django-downloadview` provides response middlewares and decorators that are +able to capture :class:`~django_downloadview.response.DownloadResponse` +instances and convert them to +:class:`~django_downloadview.response.ProxiedDownloadResponse`. -It means that decorators and middlewares are given an opportunity to capture -the ``DownloadResponse`` before the content of the file is loaded into memory -As an example, :py:class:`django_downloadview.nginx.XAccelRedirectMiddleware` -replaces ``DownloadResponse`` intance by some -:py:class:`django_downloadview.nginx.XAccelRedirectResponse`. +.. note:: + + The feature is inspired by :mod:`Django's TemplateResponse + ` -********* -Configure -********* - -Add `django_downloadview.DownloadDispatcherMiddleware` to `MIDDLEWARE_CLASSES` -in your Django settings. - -Then register as many download middlewares as you wish in -`DOWNLOADVIEW_MIDDLEWARES`. - -.. code:: python - - DOWNLOADVIEW_MIDDLEWARES = ( - ('default', - 'django_downloadview.nginx.XAccelRedirectMiddleware', - {'source_dir': MEDIA_ROOT, 'destination_url': '/proxied-download'}), - ) - -The first item is an identifier. - -The second item is the import path of some download middleware factory -(typically a class). - -The third item is a dictionary of keyword arguments passed to the middleware -factory. - - -.. rubric:: References +.. rubric:: Notes & references .. target-notes:: .. _`nginx's X-Accel`: http://wiki.nginx.org/X-accel .. _`contributions are welcome`: https://github.com/benoitbryon/django-downloadview/issues?labels=optimizations -.. _`Django's TemplateResponse`: - https://docs.djangoproject.com/en/1.5/ref/template-response/ diff --git a/docs/optimizations/nginx.txt b/docs/optimizations/nginx.txt index f54fdee..8430fb0 100644 --- a/docs/optimizations/nginx.txt +++ b/docs/optimizations/nginx.txt @@ -2,8 +2,8 @@ Nginx ##### -If you serve Django behind Nginx, then you can delegate the file download -service to Nginx and get increased performance: +If you serve Django behind Nginx, then you can delegate the file streaming +to Nginx and get increased performance: * lower resources used by Python/Django workers ; * faster download. @@ -11,103 +11,94 @@ service to Nginx and get increased performance: See `Nginx X-accel documentation`_ for details. -**************************** -Configure some download view -**************************** +************ +Given a view +************ -Let's start in the situation described in the :doc:`demo application `: +Let's consider the following view: -* a project "demoproject" -* an application "demoproject.download" -* a :py:class:`django_downloadview.views.ObjectDownloadView` view serves files - of a "Document" model. +.. literalinclude:: /../demo/demoproject/nginx/views.py + :language: python + :lines: 1-6, 8-16 -We are to make it more efficient with Nginx. +What is important here is that the files will have an ``url`` property +implemented by storage. Let's setup an optimization rule based on that URL. .. note:: - Examples below are taken from the :doc:`demo project `. + It is generally easier to setup rules based on URL rather than based on + name in filesystem. This is because path is generally relative to storage, + whereas URL usually contains some storage identifier, i.e. it is easier to + target a specific location by URL rather than by filesystem name. -*********** -Write tests -*********** +******************************** +Setup XAccelRedirect middlewares +******************************** -Use :py:func:`django_downloadview.nginx.assert_x_accel_redirect` function as -a shortcut in your tests. +Make sure ``django_downloadview.DownloadDispatcherMiddleware`` is in +``MIDDLEWARE_CLASSES`` of your `Django` settings. -:file:`demo/demoproject/nginx/tests.py`: +Example: -.. literalinclude:: ../../demo/demoproject/nginx/tests.py +.. literalinclude:: /../demo/demoproject/settings.py :language: python - :emphasize-lines: 5, 25-34 + :lines: 61-68 -Right now, this test should fail, since you haven't implemented the view yet. +Then register as many +:class:`~django_downloadview.nginx.middlewares.XAccelRedirectMiddleware` +instances as you wish in ``DOWNLOADVIEW_MIDDLEWARES``. + +.. literalinclude:: /../demo/demoproject/settings.py + :language: python + :lines: 72-76 + +The first item is an identifier. + +The second item is the import path of +:class:`~django_downloadview.nginx.middlewares.XAccelRedirectMiddleware` class. + +The third item is a dictionary of keyword arguments passed to the middleware +factory. In the example above, we capture responses by ``source_url`` and +convert them to internal redirects to ``destination_url``. + +.. autoclass:: django_downloadview.nginx.middlewares.XAccelRedirectMiddleware + :members: + :inherited-members: + :undoc-members: + :show-inheritance: + :member-order: bysource -************ -Setup Django -************ +********************************************** +Per-view setup with x_accel_redirect decorator +********************************************** -At the end of this setup, the test should pass, but you still have to `setup -Nginx`_! +Middlewares should be enough for most use cases, but you may want per-view +configuration. For `nginx`, there is ``x_accel_redirect``: -You have two options: global setup with a middleware, or per-view setup with -decorators. +.. autofunction:: django_downloadview.nginx.decorators.x_accel_redirect -Global delegation, with XAccelRedirectMiddleware -================================================ +As an example: -If you want to delegate all file downloads to Nginx, then use -:py:class:`django_downloadview.nginx.XAccelRedirectMiddleware`. +.. literalinclude:: /../demo/demoproject/nginx/views.py + :language: python + :lines: 1-7, 17- -Register it in your settings: -.. code-block:: python +******************************************* +Test responses with assert_x_accel_redirect +******************************************* - MIDDLEWARE_CLASSES = ( - # ... - 'django_downloadview.nginx.XAccelRedirectMiddleware', - # ... - ) +Use :func:`~django_downloadview.nginx.decorators.assert_x_accel_redirect` +function as a shortcut in your tests. -Setup the middleware: - -.. code-block:: python - - NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT = MEDIA_ROOT # Could be elsewhere. - NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL = '/proxied-download' - -Optionally fine-tune the middleware. Default values are ``None``, which means -"use Nginx's defaults". - -.. code-block:: python - - NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES = False # Force no expiration. - NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING = False # Force buffering off. - NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE = False # Force limit rate off. - -Local delegation, with x_accel_redirect decorator -================================================= - -If you want to delegate file downloads to Nginx on a per-view basis, then use -:py:func:`django_downloadview.nginx.x_accel_redirect` decorator. - -:file:`demo/demoproject/nginx/views.py`: - -.. literalinclude:: ../../demo/demoproject/nginx/views.py +.. literalinclude:: /../demo/demoproject/nginx/tests.py :language: python -And use it in som URL conf, as an example in -:file:`demo/demoproject/nginx/urls.py`: +.. autofunction:: django_downloadview.nginx.tests.assert_x_accel_redirect -.. literalinclude:: ../../demo/demoproject/nginx/urls.py - :language: python - -.. note:: - - In real life, you'd certainly want to replace the "download_document" view - instead of registering a new view. +The tests above assert the `Django` part is OK. Now let's configure `nginx`. *********** @@ -136,11 +127,11 @@ Here is what you could have in :file:`/etc/nginx/sites-available/default`: # like /optimized-download/myfile.tar.gz # # See http://wiki.nginx.org/X-accel - # and https://github.com/benoitbryon/django-downloadview + # and https://django-downloadview.readthedocs.org + # location /proxied-download { internal; # Location to files on disk. - # See Django's settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT alias /var/www/files/; } @@ -158,12 +149,17 @@ section. .. note:: - ``/proxied-download`` is not available for the client, i.e. users - won't be able to download files via ``/optimized-download/``. + ``/proxied-download`` has the ``internal`` flag, so this location is not + available for the client, i.e. users are not able to download files via + ``/optimized-download/``. -.. warning:: - Make sure Nginx can read the files to download! Check permissions. +********************************************* +Assert everything goes fine with healthchecks +********************************************* + +:doc:`Healthchecks ` are the best way to check the complete +setup. ************* diff --git a/docs/overview.txt b/docs/overview.txt index 810f1e0..c4ed8b5 100644 --- a/docs/overview.txt +++ b/docs/overview.txt @@ -18,11 +18,14 @@ Here is an overview of ``django-downloadview``'s answer... Generic views cover commons patterns ************************************ -* :doc:`/views/object` when you have a model with a file field; -* :doc:`/views/storage` when you manage files in a storage; -* :doc:`/views/path` when you have an absolute filename on local filesystem; -* :doc:`/views/http` when you have an URL (the resource is proxied); -* :doc:`/views/virtual` when you generate a file dynamically. +Choose the generic view depending on the file you want to serve: + +* :doc:`/views/object`: file field in a model; +* :doc:`/views/storage`: file in a storage; +* :doc:`/views/path`: absolute filename on local filesystem; +* :doc:`/views/http`: URL (the resource is proxied); +* :doc:`/views/virtual`: bytes, text, :class:`~StringIO.StringIO`, generated + file... ************************************************* @@ -39,13 +42,7 @@ Views return DownloadResponse Views return :py:class:`~django_downloadview.response.DownloadResponse`. It is a special :py:class:`django.http.StreamingHttpResponse` where content is -encapsulated in a file wrapper. If the response is sent to the client, the file -content content is loaded. - -.. note:: - - Middlewares and decorators are given the opportunity to optimize the - streaming before file content loading. +encapsulated in a file wrapper. Learn more in :doc:`responses`. @@ -54,11 +51,14 @@ Learn more in :doc:`responses`. DownloadResponse carry file wrapper *********************************** -A download view instanciates a :doc:`file wrapper ` and use it to -initialize :py:class:`~django_downloadview.response.DownloadResponse`. +Views instanciate a :doc:`file wrapper ` and use it to initialize +responses. -File wrappers describe files. They carry files properties, but not file -content. They implement loading and iterating over file content. +**File wrappers describe files**: they carry files properties such as name, +size, encoding... + +**File wrappers implement loading and iterating over file content**. Whenever +possible, file wrappers do not embed file data, in order to save memory. Learn more about available file wrappers in :doc:`files`. @@ -67,11 +67,18 @@ Learn more about available file wrappers in :doc:`files`. Middlewares convert DownloadResponse into ProxiedDownloadResponse ***************************************************************** -Decorators and middlewares may capture -:py:class:`~django_downloadview.response.DownloadResponse` instances in order -to optimize the streaming. A good optimization is to delegate streaming to -reverse proxies such as Nginx via X-Accel redirections or Apache via -X-Sendfile. +Before WSGI application use file wrapper to load file contents, middlewares +(or decorators) are given the opportunity to capture +:class:`~django_downloadview.response.DownloadResponse` instances. + +Let's take this opportunity to optimize file loading and streaming! + +A good optimization it to delegate streaming to a reverse proxy, such as +`nginx`_ via `X-Accel`_ internal redirects. + +`django_downloadview` provides middlewares that convert +:class:`~django_downloadview.response.DownloadResponse` into +:class:`~django_downloadview.response.ProxiedDownloadResponse`. Learn more in :doc:`optimizations/index`. @@ -80,12 +87,23 @@ Learn more in :doc:`optimizations/index`. Testing matters *************** -``django-downloadview`` also helps you :doc:`test the views you customized +`django-downloadview` also helps you :doc:`test the views you customized `. +You may also :doc:`write healthchecks ` to make sure everything +goes fine in live environments. + ************ What's next? ************ -Convinced? Let's :doc:`install django-downloadview `. +Let's :doc:`install django-downloadview `. + + +.. rubric:: Notes & references + +.. target-notes:: + +.. _`nginx`: http://nginx.org +.. _`X-Accel`: http://wiki.nginx.org/X-accel diff --git a/docs/responses.txt b/docs/responses.txt index a028d7f..daa4372 100644 --- a/docs/responses.txt +++ b/docs/responses.txt @@ -2,15 +2,31 @@ Responses ######### +.. currentmodule:: django_downloadview.response -******************** -``DownloadResponse`` -******************** +Views return :class:`DownloadResponse`. + +Middlewares (and decorators) are given the opportunity to capture responses and +convert them to :class:`ProxiedDownloadResponse`. +**************** +DownloadResponse +**************** -*************************** -``ProxiedDownloadResponse`` -*************************** +.. autoclass:: DownloadResponse + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +*********************** +ProxiedDownloadResponse +*********************** + +.. autoclass:: ProxiedDownloadResponse + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/settings.txt b/docs/settings.txt index 7a2d090..d289d7d 100644 --- a/docs/settings.txt +++ b/docs/settings.txt @@ -24,16 +24,9 @@ response content such as gzip middleware. Example: -.. code:: python - - MIDDLEWARE_CLASSES = [ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django_downloadview.DownloadDispatcherMiddleware', - ] +.. literalinclude:: /../demo/demoproject/settings.py + :language: python + :lines: 61-68 ************************ @@ -59,13 +52,14 @@ The list expects items ``(id, path, options)`` such as: Example: -.. code:: python - - DOWNLOADVIEW_MIDDLEWARES = ( - ('default', - 'django_downloadview.nginx.XAccelMiddleware', - {'source_dir': MEDIA_ROOT, 'destination_url': '/proxied-download'}), - ) +.. literalinclude:: /../demo/demoproject/settings.py + :language: python + :lines: 72-76 See :doc:`/optimizations/index` for details about middlewares and their options. + +.. note:: + + You can register several middlewares. It allows you to setup several + conversion rules with distinct source/destination patterns. diff --git a/docs/testing.txt b/docs/testing.txt index 0b749a3..7aebe3a 100644 --- a/docs/testing.txt +++ b/docs/testing.txt @@ -2,6 +2,32 @@ Write tests ########### -This project includes shortcuts to simplify testing. +`django_downloadview` embeds test utilities: -See :py:mod:`django_downloadview.test` for details. +* :func:`~django_downloadview.test.assert_download_response` +* :func:`~django_downloadview.test.temporary_media_root` +* :func:`~django_downloadview.nginx.tests.assert_x_accel_redirect` + + +************************ +assert_download_response +************************ + +.. autofunction:: django_downloadview.test.assert_download_response + + +******************** +temporary_media_root +******************** + +.. autofunction:: django_downloadview.test.temporary_media_root + + +******* +Example +******* + +Here are the tests related to :doc:`StorageDownloadView demo `: + +.. literalinclude:: /../demo/demoproject/storage/tests.py + :language: python diff --git a/docs/views/custom.txt b/docs/views/custom.txt index 3b3a204..5ecda28 100644 --- a/docs/views/custom.txt +++ b/docs/views/custom.txt @@ -2,6 +2,7 @@ Make your own view ################## +.. currentmodule:: django_downloadview.views.base ************* DownloadMixin @@ -13,6 +14,12 @@ is a base class which you can inherit of to create custom download views. ``DownloadMixin`` is a base of `BaseDownloadView`_, which itself is a base of all other django_downloadview's builtin views. +.. autoclass:: DownloadMixin + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + **************** BaseDownloadView @@ -26,3 +33,44 @@ The only thing it does is to implement :py:meth:`get `: it triggers :py:meth:`DownloadMixin's render_to_response `. + +.. autoclass:: BaseDownloadView + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + + +************************************ +Handling http not modified responses +************************************ + +Sometimes, you know the latest date and time the content was generated at, and +you know a new request would generate exactly the same content. In such a case, +you should implement :py:meth:`~VirtualDownloadView.was_modified_since` in your +view. + +.. note:: + + Default :py:meth:`~VirtualDownloadView.was_modified_since` implementation + trusts file wrapper's ``was_modified_since`` if any. Else (if calling + ``was_modified_since()`` raises ``NotImplementedError`` or + ``AttributeError``) it returns ``True``, i.e. it assumes the file was + modified. + +As an example, the download views above always generate "Hello world!"... so, +if the client already downloaded it, we can safely return some HTTP "304 Not +Modified" response: + +.. code:: python + + from django.core.files.base import ContentFile + from django_downloadview import VirtualDownloadView + + class TextDownloadView(VirtualDownloadView): + def get_file(self): + """Return :class:`django.core.files.base.ContentFile` object.""" + return ContentFile(u"Hello world!", name='hello-world.txt') + + def was_modified_since(self, file_instance, since): + return False # Never modified, always u"Hello world!". diff --git a/docs/views/http.txt b/docs/views/http.txt index 294db2e..4d334dc 100644 --- a/docs/views/http.txt +++ b/docs/views/http.txt @@ -22,14 +22,8 @@ Simple example Setup a view to stream files given URL: -.. code:: python - - from django_downloadview import HTTPDownloadView - - class TravisStatusView(HTTPDownloadView): - def get_url(self): - """Return URL of django-downloadview's build status.""" - return u'https://travis-ci.org/benoitbryon/django-downloadview.png' +.. literalinclude:: /../demo/demoproject/http/views.py + :language: python ************* diff --git a/docs/views/object.txt b/docs/views/object.txt index d84c48f..955d960 100644 --- a/docs/views/object.txt +++ b/docs/views/object.txt @@ -21,39 +21,45 @@ Simple example Given a model with a :class:`~django.db.models.FileField`: -.. code:: python +.. literalinclude:: /../demo/demoproject/object/models.py + :language: python + :lines: 1-6 - from django.db import models - - class Document(models.Model): - file = models.FileField(upload_to='document') Setup a view to stream the ``file`` attribute: -.. code:: python - - from django_downloadview import ObjectDownloadView - - download = ObjectDownloadView.as_view(model=Document) - -.. note:: - - If the file field you want to serve is not named "file", pass the right - name as "file_field" argument, i.e. adapt - ``ObjectDownloadView.as_view(model=Document, file_field='file')``. +.. literalinclude:: /../demo/demoproject/object/views.py + :language: python + :lines: 1-5, 7 :class:`~django_downloadview.views.object.ObjectDownloadView` inherits from :class:`~django.views.generic.detail.BaseDetailView`, i.e. it expects either ``slug`` or ``pk``: -.. code:: python +.. literalinclude:: /../demo/demoproject/object/urls.py + :language: python + :lines: 1-7, 8-10, 17 - from django.conf.urls import url, url_patterns - url_patterns = ('', - url('^download/(?P[A-Za-z0-9_-]+)/$', download, name='download'), - ) +*************************** +Serving specific file field +*************************** +If your model holds several file fields, or if the file field name is not +"file", you can use :attr:`ObjectDownloadView.file_field` to specify the field +to use. + +Here is a model where there are two file fields: + +.. literalinclude:: /../demo/demoproject/object/models.py + :language: python + :lines: 1-6, 7 + +Then here is the code to serve "another_file" instead of the default "file": + +.. literalinclude:: /../demo/demoproject/object/views.py + :language: python + :lines: 1-5, 10-12 ********************************** Mapping file attributes to model's @@ -65,24 +71,19 @@ can be used when you serve the file. As an example, let's consider the client-side basename lives in model and not in storage: -.. code:: python - - from django.db import models - - class Document(models.Model): - file = models.FileField(upload_to='document') - basename = models.CharField(max_length=100) +.. literalinclude:: /../demo/demoproject/object/models.py + :language: python + :lines: 1-6, 8 Then you can configure the :attr:`ObjectDownloadView.basename_field` option: -.. code:: python +.. literalinclude:: /../demo/demoproject/object/views.py + :language: python + :lines: 1-5, 16-18 - from django_downloadview import ObjectDownloadView +.. note:: - download = ObjectDownloadView.as_view(model=Document, - basename_field='basename') - -.. note:: ``basename`` could have been a property instead of a database field. + ``basename`` could have been a model's property instead of a ``CharField``. See details below for a full list of options. diff --git a/docs/views/path.txt b/docs/views/path.txt index 10a95ed..f7d177f 100644 --- a/docs/views/path.txt +++ b/docs/views/path.txt @@ -22,45 +22,10 @@ Simple example Setup a view to stream files given path: -.. code:: python - - from django_downloadview import PathDownloadView - - download = PathDownloadView.as_view() - -The view accepts a ``path`` argument you can setup either in ``as_view`` or -via URLconfs: - -.. code:: python - - from django.conf.urls import patterns, url - - urlpatterns = patterns( - '', - url(r'^(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', download), - ) - - -************************************ -A convenient shortcut in other views -************************************ - -:class:`PathDownloadView` is straight to call, so you can use it to simplify -code in more complex views, provided you have an absolute path to a local file: - -.. code:: python - - from django_downloadview import PathDownloadView - - def some_complex_view(request, *args, **kwargs): - """Does many things, then stream a file.""" - local_path = do_many_things() - return PathDownloadView.as_view(path=local_path)(request) - -.. note:: - - `django-sendfile`_ users may like something such as - ``sendfile = lambda request, path: PathDownloadView.as_view(path=path)(request)`` +.. literalinclude:: /../demo/demoproject/path/views.py + :language: python + :lines: 1-14 + :emphasize-lines: 14 ************************** @@ -70,20 +35,16 @@ Computing path dynamically Override the :meth:`PathDownloadView.get_path` method to adapt path resolution to your needs: -.. code:: python +.. literalinclude:: /../demo/demoproject/path/views.py + :language: python + :lines: 1-9, 15- - import glob - import os - import random - from django.conf import settings - from django_downloadview import PathDownloadView +The view accepts a ``path`` argument you can setup either in ``as_view`` or +via URLconfs: - class RandomImageView(PathDownloadView): - """Stream a random image in ``MEDIA_ROOT``.""" - def get_path(self): - """Return the path of a random JPG image in ``MEDIA_ROOT``.""" - image_list = glob.glob(os.path.join(settings.MEDIA_ROOT, '*.jpg')) - return random.choice(image_list) +.. literalinclude:: /../demo/demoproject/path/urls.py + :language: python + :lines: 1-7, 11-13, 14 ************* @@ -95,10 +56,3 @@ API reference :undoc-members: :show-inheritance: :member-order: bysource - - -.. rubric:: Notes & references - -.. target-notes:: - -.. _`django-sendfile`: https://pypi.python.org/pypi/django-sendfile diff --git a/docs/views/storage.txt b/docs/views/storage.txt index fc35f37..9878d88 100644 --- a/docs/views/storage.txt +++ b/docs/views/storage.txt @@ -16,31 +16,22 @@ Simple example Given a storage: -.. code:: python - - from django.core.files.storage import FileSystemStorage - - storage = FileSystemStorage(location='/somewhere') +.. literalinclude:: /../demo/demoproject/storage/views.py + :language: python + :lines: 1, 4-6 Setup a view to stream files in storage: -.. code:: python - - from django_downloadview import StorageDownloadView - - download = StorageDownloadView.as_view(storage=storage) +.. literalinclude:: /../demo/demoproject/storage/views.py + :language: python + :lines: 3-5, 10 The view accepts a ``path`` argument you can setup either in ``as_view`` or via URLconfs: -.. code:: python - - from django.conf.urls import patterns, url - - urlpatterns = patterns( - '', - url(r'^(?P[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$', download), - ) +.. literalinclude:: /../demo/demoproject/storage/urls.py + :language: python + :lines: 1-7, 8-10, 14 ************************** @@ -50,6 +41,13 @@ Computing path dynamically Override the :meth:`StorageDownloadView.get_path` method to adapt path resolution to your needs. +As an example, here is the same view as above, but the path is converted to +uppercase: + +.. literalinclude:: /../demo/demoproject/storage/views.py + :language: python + :lines: 3-5, 13-20 + ************* API reference diff --git a/docs/views/virtual.txt b/docs/views/virtual.txt index ce77049..706d6af 100644 --- a/docs/views/virtual.txt +++ b/docs/views/virtual.txt @@ -26,15 +26,9 @@ Let's consider you build text dynamically, as a bytes or string or unicode object. Serve it with Django's builtin :class:`~django.core.files.base.ContentFile` wrapper: -.. code:: python - - from django.core.files.base import ContentFile - from django_downloadview import VirtualDownloadView - - class TextDownloadView(VirtualDownloadView): - def get_file(self): - """Return :class:`django.core.files.base.ContentFile` object.""" - return ContentFile(u"Hello world!", name='hello-world.txt') +.. literalinclude:: /../demo/demoproject/virtual/views.py + :language: python + :lines: 3-5, 8-13 ************** @@ -44,16 +38,9 @@ Serve StringIO :class:`~StringIO.StringIO` object lives in memory. Let's wrap it in some download view via :class:`~django_downloadview.files.VirtualFile`: -.. code:: python - - from StringIO import StringIO - from django_downloadview import VirtualDownloadView, VirtualFile - - class StringIODownloadView(VirtualDownloadView): - def get_file(self): - """Return wrapper on ``StringIO`` object.""" - file_obj = StringIO(u"Hello world!\n".encode('utf-8')) - return VirtualFile(file_obj, name='hello-world.txt') +.. literalinclude:: /../demo/demoproject/virtual/views.py + :language: python + :lines: 1-2, 5-6, 14-20 ************************ @@ -63,64 +50,19 @@ Stream generated content Let's consider you have a generator function (``yield``) or an iterator object (``__iter__()``): -.. code:: python - def generate_hello(): - yield u'Hello ' - yield u'world!' +.. literalinclude:: /../demo/demoproject/virtual/views.py + :language: python + :lines: 23-26 + Stream generated content using :class:`VirtualDownloadView`, :class:`~django_downloadview.files.VirtualFile` and :class:`~django_downloadview.file.StringIteratorIO`: -.. code:: python - - from django_downloadview import (VirtualDownloadView, - VirtualFile, - StringIteratorIO) - - class GeneratedDownloadView(VirtualDownloadView): - def get_file(self): - """Return wrapper on ``StringIteratorIO`` object.""" - file_obj = StringIteratorIO(generate_hello()) - return VirtualFile(file_obj, name='hello-world.txt') - - -************************************ -Handling http not modified responses -************************************ - -Sometimes, you know the latest date and time the content was generated at, and -you know a new request would generate exactly the same content. In such a case, -you should implement :py:meth:`~VirtualDownloadView.was_modified_since` in your -view. - -.. note:: - - Default :py:meth:`~VirtualDownloadView.was_modified_since` implementation - trusts file wrapper's ``was_modified_since`` if any. Else (if calling - ``was_modified_since()`` raises ``NotImplementedError`` or - ``AttributeError``) it returns ``True``, i.e. it assumes the file was - modified. - -As an example, the download views above always generate "Hello world!"... so, -if the client already downloaded it, we can safely return some HTTP "304 Not -Modified" response: - -.. code:: python - - from django.core.files.base import ContentFile - from django_downloadview import VirtualDownloadView - - class TextDownloadView(VirtualDownloadView): - def get_file(self): - """Return :class:`django.core.files.base.ContentFile` object.""" - return ContentFile(u"Hello world!", name='hello-world.txt') - - def was_modified_since(self, file_instance, since): - return False # Never modified, always u"Hello world!". - - +.. literalinclude:: /../demo/demoproject/virtual/views.py + :language: python + :lines: 5-9, 29-33 ************* From 9f82aea15b5348b212d813bed7c49438e657c704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 28 Oct 2013 18:04:43 +0100 Subject: [PATCH 17/25] Updated description and keywords for PyPI. --- setup.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index d92042c..779de53 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ REQUIRES = ['setuptools', 'django>=1.5', 'requests'] if __name__ == '__main__': # Don't run setup() when we import this module. setup(name=NAME, version=VERSION, - description='Generic download views for Django.', + description='Serve files with Django and reverse-proxies.', long_description=README, classifiers=['Development Status :: 4 - Beta', 'License :: OSI Approved :: BSD License', @@ -29,8 +29,9 @@ if __name__ == '__main__': # Don't run setup() when we import this module. 'Programming Language :: Python :: 2.6', 'Framework :: Django', ], - keywords='class-based view, generic view, download, file, ' - 'FileField, ImageField, nginx, x-accel, x-sendfile', + keywords='file stream download FileField ImageField x-accel ' + 'x-accel-redirect x-sendfile sendfile mod_xsendfile ' + 'offload', author='Benoît Bryon', author_email='benoit@marmelune.net', url='https://github.com/benoitbryon/%s' % NAME, From 2e5d7786514c7d46be9917993bfab29c352c356a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 28 Oct 2013 18:15:09 +0100 Subject: [PATCH 18/25] Renamed README to README.rst. Improved setup.py readability. --- README => README.rst | 0 docs/index.txt | 2 +- setup.py | 59 ++++++++++++++++++++++++++------------------ tests/packaging.py | 2 +- 4 files changed, 37 insertions(+), 26 deletions(-) rename README => README.rst (100%) diff --git a/README b/README.rst similarity index 100% rename from README rename to README.rst diff --git a/docs/index.txt b/docs/index.txt index 38aafc5..5c0095f 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -1,4 +1,4 @@ -.. include:: ../README +.. include:: ../README.rst ******** diff --git a/setup.py b/setup.py index 779de53..72909f2 100644 --- a/setup.py +++ b/setup.py @@ -1,43 +1,54 @@ # -*- coding: utf-8 -*- """Python packaging.""" import os + from setuptools import setup -def read_relative_file(filename): - """Returns contents of the given file, which path is supposed relative - to this module.""" - with open(os.path.join(os.path.dirname(__file__), filename)) as f: - return f.read().strip() +here = os.path.abspath(os.path.dirname(__file__)) NAME = 'django-downloadview' -README = read_relative_file('README') -VERSION = read_relative_file('VERSION') -PACKAGES = ['django_downloadview'] -REQUIRES = ['setuptools', 'django>=1.5', 'requests'] +DESCRIPTION = 'Serve files with Django and reverse-proxies.' +README = open(os.path.join(here, 'README.rst')).read() +VERSION = open(os.path.join(here, 'VERSION')).read().strip() +AUTHOR = u'Benoît Bryon' +EMAIL = u'benoit@marmelune.net' +URL = 'https://{name}.readthedocs.org/'.format(name=NAME) +CLASSIFIERS = ['Development Status :: 4 - Beta', + 'License :: OSI Approved :: BSD License', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 2.6', + 'Framework :: Django'] +KEYWORDS = ['file', + 'stream', + 'download', + 'FileField', + 'ImageField', + 'x-accel', + 'x-accel-redirect', + 'x-sendfile', + 'sendfile', + 'mod_xsendfile', + 'offload'] +PACKAGES = [NAME.replace('-', '_')] +REQUIREMENTS = ['setuptools', 'Django>=1.5', 'requests'] +ENTRY_POINTS = {} if __name__ == '__main__': # Don't run setup() when we import this module. setup(name=NAME, version=VERSION, - description='Serve files with Django and reverse-proxies.', + description=DESCRIPTION, long_description=README, - classifiers=['Development Status :: 4 - Beta', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 2.6', - 'Framework :: Django', - ], - keywords='file stream download FileField ImageField x-accel ' - 'x-accel-redirect x-sendfile sendfile mod_xsendfile ' - 'offload', - author='Benoît Bryon', - author_email='benoit@marmelune.net', - url='https://github.com/benoitbryon/%s' % NAME, + classifiers=CLASSIFIERS, + keywords=' '.join(KEYWORDS), + author=AUTHOR, + author_email=EMAIL, + url=URL, license='BSD', packages=PACKAGES, include_package_data=True, zip_safe=False, - install_requires=REQUIRES, - ) + install_requires=REQUIREMENTS, + entry_points=ENTRY_POINTS) diff --git a/tests/packaging.py b/tests/packaging.py index bf2b42e..97b673e 100644 --- a/tests/packaging.py +++ b/tests/packaging.py @@ -59,7 +59,7 @@ class ReadMeTestCase(unittest.TestCase): # Run build. import docutils.core import docutils.io - source = open(os.path.join(project_dir, 'README')).read() + source = open(os.path.join(project_dir, 'README.rst')).read() writer_name = 'html' import sys from StringIO import StringIO From 17900944223e3a0d3a04c45523bf46274407255e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 28 Oct 2013 21:07:42 +0100 Subject: [PATCH 19/25] Standardized demo's packaging. --- demo/{README => README.rst} | 2 +- demo/setup.py | 71 +++++++++++++++++++------------------ docs/demo.txt | 2 +- 3 files changed, 38 insertions(+), 37 deletions(-) rename demo/{README => README.rst} (95%) diff --git a/demo/README b/demo/README.rst similarity index 95% rename from demo/README rename to demo/README.rst index 73d11cc..dc9a78d 100644 --- a/demo/README +++ b/demo/README.rst @@ -10,7 +10,7 @@ Demo project Documentation includes code from the demo ***************************************** -Almost every example in this documentation comes from the demo: +Almost every example in the documentation comes from the demo: * discover examples in the documentation; * browse related code and tests in demo project. diff --git a/demo/setup.py b/demo/setup.py index 40122f7..810b219 100644 --- a/demo/setup.py +++ b/demo/setup.py @@ -1,46 +1,47 @@ -# coding=utf-8 +# -*- coding: utf-8 -*- """Python packaging.""" import os + from setuptools import setup -def read_relative_file(filename): - """Returns contents of the given file, which path is supposed relative - to this module.""" - with open(os.path.join(os.path.dirname(__file__), filename)) as f: - return f.read() +here = os.path.abspath(os.path.dirname(__file__)) +project_root = os.path.dirname(here) NAME = 'django-downloadview-demo' -README = read_relative_file('README') -VERSION = '0.1' +DESCRIPTION = 'Serve files with Django and reverse-proxies.' +README = open(os.path.join(here, 'README.rst')).read() +VERSION = open(os.path.join(project_root, 'VERSION')).read().strip() +AUTHOR = u'Benoît Bryon' +EMAIL = u'benoit@marmelune.net' +URL = 'https://{name}.readthedocs.org/'.format(name=NAME) +CLASSIFIERS = ['Development Status :: 4 - Beta', + 'License :: OSI Approved :: BSD License', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 2.6', + 'Framework :: Django'] +KEYWORDS = [] PACKAGES = ['demoproject'] -REQUIRES = ['django-downloadview', - 'django-nose'] +REQUIREMENTS = ['django-downloadview', 'django-nose'] +ENTRY_POINTS = { + 'console_scripts': ['demo = demoproject.manage:main'] +} -setup(name=NAME, - version=VERSION, - description='Demo project for Django-DownloadView.', - long_description=README, - classifiers=['Development Status :: 1 - Planning', - 'License :: OSI Approved :: BSD License', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 2.6', - 'Framework :: Django', - ], - keywords='class-based view, generic view, download', - author='Benoit Bryon', - author_email='benoit@marmelune.net', - url='https://github.com/benoitbryon/%s' % NAME, - license='BSD', - packages=PACKAGES, - include_package_data=True, - zip_safe=False, - install_requires=REQUIRES, - entry_points={ - 'console_scripts': [ - 'demo = demoproject.manage:main', - ] - }, - ) +if __name__ == '__main__': # Don't run setup() when we import this module. + setup(name=NAME, + version=VERSION, + description=DESCRIPTION, + long_description=README, + classifiers=CLASSIFIERS, + keywords=' '.join(KEYWORDS), + author=AUTHOR, + author_email=EMAIL, + url=URL, + license='BSD', + packages=PACKAGES, + include_package_data=True, + zip_safe=False, + install_requires=REQUIREMENTS, + entry_points=ENTRY_POINTS) diff --git a/docs/demo.txt b/docs/demo.txt index e5dd96e..9fdd6d7 100644 --- a/docs/demo.txt +++ b/docs/demo.txt @@ -1 +1 @@ -.. include:: ../demo/README +.. include:: ../demo/README.rst From 4c3787b2a06d8b6642b859838e00abe8ac2ad179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Tue, 29 Oct 2013 09:25:48 +0100 Subject: [PATCH 20/25] Refs #25 - Added unit tests about API and deprecated settings. --- django_downloadview/__init__.py | 2 +- django_downloadview/api.py | 5 +- django_downloadview/nginx/__init__.py | 3 +- django_downloadview/nginx/settings.py | 24 ++++-- django_downloadview/tests/__init__.py | 2 + django_downloadview/tests/api.py | 116 ++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 django_downloadview/tests/__init__.py create mode 100644 django_downloadview/tests/api.py diff --git a/django_downloadview/__init__.py b/django_downloadview/__init__.py index 11c6eed..f25b9e5 100644 --- a/django_downloadview/__init__.py +++ b/django_downloadview/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""django-downloadview provides generic download views for Django.""" +"""Serve files with Django and reverse proxies.""" import pkg_resources diff --git a/django_downloadview/api.py b/django_downloadview/api.py index a51f821..2167762 100644 --- a/django_downloadview/api.py +++ b/django_downloadview/api.py @@ -9,12 +9,13 @@ from django_downloadview.response import (DownloadResponse, # NoQA ProxiedDownloadResponse) from django_downloadview.middlewares import (BaseDownloadMiddleware, # NoQA DownloadDispatcherMiddleware) -from django_downloadview.nginx import XAccelRedirectMiddleware # NoQA from django_downloadview.views import (PathDownloadView, # NoQA ObjectDownloadView, StorageDownloadView, HTTPDownloadView, - VirtualDownloadView) + VirtualDownloadView, + BaseDownloadView, + DownloadMixin) from django_downloadview.sendfile import sendfile # NoQA from django_downloadview.test import (assert_download_response, # NoQA temporary_media_root) diff --git a/django_downloadview/nginx/__init__.py b/django_downloadview/nginx/__init__.py index 26b5053..4cf9805 100644 --- a/django_downloadview/nginx/__init__.py +++ b/django_downloadview/nginx/__init__.py @@ -11,5 +11,4 @@ from django_downloadview.nginx.decorators import x_accel_redirect # NoQA from django_downloadview.nginx.response import XAccelRedirectResponse # NoQA from django_downloadview.nginx.tests import assert_x_accel_redirect # NoQA from django_downloadview.nginx.middlewares import ( # NoQA - XAccelRedirectMiddleware, - SingleXAccelRedirectMiddleware) + XAccelRedirectMiddleware) diff --git a/django_downloadview/nginx/settings.py b/django_downloadview/nginx/settings.py index 9851c84..9c5b860 100644 --- a/django_downloadview/nginx/settings.py +++ b/django_downloadview/nginx/settings.py @@ -41,7 +41,8 @@ setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING' if hasattr(settings, setting_name): warnings.warn('settings.{deprecated} is deprecated. You should combine ' '"django_downloadview.DownloadDispatcherMiddleware" with ' - 'with DOWNLOADVIEW_MIDDLEWARES instead.', + 'with DOWNLOADVIEW_MIDDLEWARES instead.'.format( + deprecated=setting_name), DeprecationWarning) if not hasattr(settings, setting_name): setattr(settings, setting_name, DEFAULT_WITH_BUFFERING) @@ -62,7 +63,8 @@ setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE' if hasattr(settings, setting_name): warnings.warn('settings.{deprecated} is deprecated. You should combine ' '"django_downloadview.DownloadDispatcherMiddleware" with ' - 'with DOWNLOADVIEW_MIDDLEWARES instead.', + 'with DOWNLOADVIEW_MIDDLEWARES instead.'.format( + deprecated=setting_name), DeprecationWarning) if not hasattr(settings, setting_name): setattr(settings, setting_name, DEFAULT_LIMIT_RATE) @@ -83,7 +85,8 @@ setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES' if hasattr(settings, setting_name): warnings.warn('settings.{deprecated} is deprecated. You should combine ' '"django_downloadview.DownloadDispatcherMiddleware" with ' - 'with DOWNLOADVIEW_MIDDLEWARES instead.', + 'with DOWNLOADVIEW_MIDDLEWARES instead.'.format( + deprecated=setting_name), DeprecationWarning) if not hasattr(settings, setting_name): setattr(settings, setting_name, DEFAULT_EXPIRES) @@ -95,14 +98,16 @@ setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT' if hasattr(settings, setting_name): warnings.warn('settings.{deprecated} is deprecated. You should combine ' '"django_downloadview.DownloadDispatcherMiddleware" with ' - 'with DOWNLOADVIEW_MIDDLEWARES instead.', + 'with DOWNLOADVIEW_MIDDLEWARES instead.'.format( + deprecated=setting_name), DeprecationWarning) DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR' if hasattr(settings, setting_name): warnings.warn('settings.{deprecated} is deprecated. You should combine ' '"django_downloadview.DownloadDispatcherMiddleware" with ' - 'with DOWNLOADVIEW_MIDDLEWARES instead.', + 'with DOWNLOADVIEW_MIDDLEWARES instead.'.format( + deprecated=setting_name), DeprecationWarning) if not hasattr(settings, setting_name): setattr(settings, setting_name, DEFAULT_SOURCE_DIR) @@ -114,7 +119,8 @@ setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL' if hasattr(settings, setting_name): warnings.warn('settings.{deprecated} is deprecated. You should combine ' '"django_downloadview.DownloadDispatcherMiddleware" with ' - 'with DOWNLOADVIEW_MIDDLEWARES instead.', + 'with DOWNLOADVIEW_MIDDLEWARES instead.'.format( + deprecated=setting_name), DeprecationWarning) if not hasattr(settings, setting_name): setattr(settings, setting_name, DEFAULT_SOURCE_URL) @@ -126,14 +132,16 @@ setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL' if hasattr(settings, setting_name): warnings.warn('settings.{deprecated} is deprecated. You should combine ' '"django_downloadview.DownloadDispatcherMiddleware" with ' - 'with DOWNLOADVIEW_MIDDLEWARES instead.', + 'with DOWNLOADVIEW_MIDDLEWARES instead.'.format( + deprecated=setting_name), DeprecationWarning) DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL' if hasattr(settings, setting_name): warnings.warn('settings.{deprecated} is deprecated. You should combine ' '"django_downloadview.DownloadDispatcherMiddleware" with ' - 'with DOWNLOADVIEW_MIDDLEWARES instead.', + 'with DOWNLOADVIEW_MIDDLEWARES instead.'.format( + deprecated=setting_name), DeprecationWarning) if not hasattr(settings, setting_name): setattr(settings, setting_name, DEFAULT_DESTINATION_URL) diff --git a/django_downloadview/tests/__init__.py b/django_downloadview/tests/__init__.py new file mode 100644 index 0000000..0f38c3e --- /dev/null +++ b/django_downloadview/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Unit tests.""" diff --git a/django_downloadview/tests/api.py b/django_downloadview/tests/api.py new file mode 100644 index 0000000..5adc0d7 --- /dev/null +++ b/django_downloadview/tests/api.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""Test suite around :mod:`django_downloadview.api` and deprecation plan.""" +import unittest +import warnings + +from django.core.exceptions import ImproperlyConfigured +import django.test +from django.test.utils import override_settings +from django.utils.importlib import import_module + + +class APITestCase(unittest.TestCase): + """Make sure django_downloadview exposes API.""" + def assert_module_attributes(self, module_path, attribute_names): + """Assert imported ``module_path`` has ``attribute_names``.""" + module = import_module(module_path) + missing_attributes = [] + for attribute_name in attribute_names: + if not hasattr(module, attribute_name): + missing_attributes.append(attribute_name) + if missing_attributes: + self.fail('Missing attributes in "{module}": {attributes}'.format( + module=module_path, attributes=', '.join(missing_attributes))) + + def test_root_attributes(self): + """API is exposed in django_downloadview root package. + + The goal of this test is to make sure that main items of project's API + are easy to import... and prevent refactoring from breaking main API. + + If this test is broken by refactoring, a :class:`DeprecationWarning` or + simimar should be raised. + + """ + api = [ + # Views: + 'ObjectDownloadView', + 'StorageDownloadView', + 'PathDownloadView', + 'HTTPDownloadView', + 'VirtualDownloadView', + 'BaseDownloadView', + 'DownloadMixin', + # File wrappers: + 'File', + 'StorageFile', + 'HTTPFile', + 'VirtualFile', + # Responses: + 'DownloadResponse', + 'ProxiedDownloadResponse', + # Middlewares: + 'BaseDownloadMiddleware', + 'DownloadDispatcherMiddleware', + # Testing: + 'assert_download_response', + 'temporary_media_root', + # Utilities: + 'StringIteratorIO', + 'sendfile'] + self.assert_module_attributes('django_downloadview', api) + + def test_nginx_attributes(self): + """Nginx-related API is exposed in django_downloadview.nginx.""" + api = [ + 'XAccelRedirectResponse', + 'XAccelRedirectMiddleware', + 'x_accel_redirect', + 'assert_x_accel_redirect'] + self.assert_module_attributes('django_downloadview.nginx', api) + + +class DeprecatedAPITestCase(django.test.SimpleTestCase): + """Make sure using deprecated items raise DeprecationWarning.""" + def test_nginx_x_accel_redirect_middleware(self): + "XAccelRedirectMiddleware in settings triggers ImproperlyConfigured." + with override_settings( + MIDDLEWARE_CLASSES=[ + 'django_downloadview.nginx.XAccelRedirectMiddleware']): + with self.assertRaises(ImproperlyConfigured): + import django_downloadview.nginx.settings + reload(django_downloadview.nginx.settings) + + def test_nginx_x_accel_redirect_global_settings(self): + """Global settings for Nginx middleware are deprecated.""" + settings_overrides = { + 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING': True, + 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE': 32, + 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES': 3600, + 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT': '/', + 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR': '/', + 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL': '/', + 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL': '/', + 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL': '/', + } + import django_downloadview.nginx.settings + missed_warnings = [] + for setting_name, setting_value in settings_overrides.items(): + warnings.resetwarnings() + warnings.simplefilter("always") + with warnings.catch_warnings(record=True) as warning_list: + with override_settings(**{setting_name: setting_value}): + reload(django_downloadview.nginx.settings) + caught = False + for warning_item in warning_list: + if warning_item.category == DeprecationWarning: + if 'deprecated' in str(warning_item.message): + if setting_name in str(warning_item.message): + caught = True + break + if not caught: + missed_warnings.append(setting_name) + if missed_warnings: + self.fail( + 'No DeprecationWarning raised about following settings: ' + '{settings}.'.format(settings=', '.join(missed_warnings))) From def3e97a393fb4d7e9d1071e011595f553552ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Mon, 4 Nov 2013 23:20:13 +0100 Subject: [PATCH 21/25] Added unit tests for DownloadMixin. --- django_downloadview/tests/views.py | 164 +++++++++++++++++++++++++++++ django_downloadview/views/base.py | 26 +++-- 2 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 django_downloadview/tests/views.py diff --git a/django_downloadview/tests/views.py b/django_downloadview/tests/views.py new file mode 100644 index 0000000..6399f8c --- /dev/null +++ b/django_downloadview/tests/views.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""Unit tests around views.""" +import unittest +try: + from unittest import mock +except ImportError: + import mock + +from django.http.response import HttpResponseNotModified +import django.test + +from django_downloadview.tests import setup_view +from django_downloadview.views import base + + +class DownloadMixinTestCase(unittest.TestCase): + """Tests around :class:`django_downloadviews.views.base.DownloadMixin`.""" + def test_get_file(self): + """DownloadMixin.get_file() raise NotImplementedError. + + Subclasses must implement it! + + """ + mixin = base.DownloadMixin() + with self.assertRaises(NotImplementedError): + mixin.get_file() + + def test_get_basename(self): + """DownloadMixin.get_basename() returns basename attribute.""" + mixin = base.DownloadMixin() + self.assertEqual(mixin.get_basename(), None) + mixin.basename = 'fake' + self.assertEqual(mixin.get_basename(), 'fake') + + def test_was_modified_since_file(self): + """DownloadMixin.was_modified_since() tries (1) file's implementation. + + :meth:`django_downloadview.views.base.DownloadMixin.was_modified_since` + first tries to delegate computations to file wrapper's implementation. + + """ + file_wrapper = mock.Mock() + file_wrapper.was_modified_since = mock.Mock( + return_value=mock.sentinel.was_modified) + mixin = base.DownloadMixin() + self.assertIs( + mixin.was_modified_since(file_wrapper, mock.sentinel.since), + mock.sentinel.was_modified) + file_wrapper.was_modified_since.assertCalledOnceWith( + mock.sentinel.since) + + def test_was_modified_since_django(self): + """DownloadMixin.was_modified_since() tries (2) files attributes. + + When calling file wrapper's ``was_modified_since()`` raises + ``NotImplementedError`` or ``AttributeError``, + :meth:`django_downloadview.views.base.DownloadMixin.was_modified_since` + tries to pass file wrapper's ``size`` and ``modified_time`` to + :func:`django.views.static import was_modified_since`. + + """ + file_wrapper = mock.Mock() + file_wrapper.was_modified_since = mock.Mock( + side_effect=AttributeError) + file_wrapper.size = mock.sentinel.size + file_wrapper.modified_time = mock.sentinel.modified_time + was_modified_since_mock = mock.Mock( + return_value=mock.sentinel.was_modified) + mixin = base.DownloadMixin() + with mock.patch('django_downloadview.views.base.was_modified_since', + new=was_modified_since_mock): + self.assertIs( + mixin.was_modified_since(file_wrapper, mock.sentinel.since), + mock.sentinel.was_modified) + was_modified_since_mock.assertCalledOnceWith( + mock.sentinel.size, + mock.sentinel.modified_time) + + def test_was_modified_since_fallback(self): + """DownloadMixin.was_modified_since() fallbacks to `True`. + + When: + + * calling file wrapper's ``was_modified_since()`` raises + ``NotImplementedError`` or ``AttributeError``; + + * and accessing ``size`` and ``modified_time`` from file wrapper raises + ``NotImplementedError`` or ``AttributeError``... + + ... then + :meth:`django_downloadview.views.base.DownloadMixin.was_modified_since` + returns ``True``. + + """ + file_wrapper = mock.Mock() + file_wrapper.was_modified_since = mock.Mock( + side_effect=NotImplementedError) + type(file_wrapper).modified_time = mock.PropertyMock( + side_effect=NotImplementedError) + mixin = base.DownloadMixin() + self.assertIs( + mixin.was_modified_since(file_wrapper, 'fake since'), + True) + + def test_not_modified_response(self): + "DownloadMixin.not_modified_response returns HttpResponseNotModified." + mixin = base.DownloadMixin() + response = mixin.not_modified_response() + self.assertTrue(isinstance(response, HttpResponseNotModified)) + + def test_download_response(self): + "DownloadMixin.download_response() returns download response instance." + mixin = base.DownloadMixin() + mixin.file_instance = mock.sentinel.file_wrapper + response_factory = mock.Mock(return_value=mock.sentinel.response) + mixin.response_class = response_factory + response_kwargs = {'dummy': 'value', + 'file_instance': mock.sentinel.file_wrapper, + 'attachment': True, + 'basename': None} + response = mixin.download_response(**response_kwargs) + self.assertIs(response, mock.sentinel.response) + response_factory.assert_called_once_with(**response_kwargs) # Not args + + def test_render_to_response_not_modified(self): + """DownloadMixin.render_to_response() respects HTTP_IF_MODIFIED_SINCE + header (calls ``not_modified_response()``).""" + # Setup. + mixin = base.DownloadMixin() + mixin.request = django.test.RequestFactory().get( + '/dummy-url', + HTTP_IF_MODIFIED_SINCE=mock.sentinel.http_if_modified_since) + mixin.was_modified_since = mock.Mock(return_value=False) + mixin.not_modified_response = mock.Mock( + return_value=mock.sentinel.http_not_modified_response) + mixin.get_file = mock.Mock(return_value=mock.sentinel.file_wrapper) + # Run. + response = mixin.render_to_response() + # Check. + self.assertIs(response, mock.sentinel.http_not_modified_response) + mixin.get_file.assert_called_once_with() + mixin.was_modified_since.assert_called_once_with( + mock.sentinel.file_wrapper, + mock.sentinel.http_if_modified_since) + mixin.not_modified_response.assert_called_once_with() + + def test_render_to_response_modified(self): + """DownloadMixin.render_to_response() calls download_response().""" + # Setup. + mixin = base.DownloadMixin() + mixin.request = django.test.RequestFactory().get( + '/dummy-url', + HTTP_IF_MODIFIED_SINCE=None) + mixin.was_modified_since = mock.Mock() + mixin.download_response = mock.Mock( + return_value=mock.sentinel.download_response) + mixin.get_file = mock.Mock(return_value=mock.sentinel.file_wrapper) + # Run. + response = mixin.render_to_response() + # Check. + self.assertIs(response, mock.sentinel.download_response) + mixin.get_file.assert_called_once_with() + self.assertEqual(mixin.was_modified_since.call_count, 0) + mixin.download_response.assert_called_once_with() diff --git a/django_downloadview/views/base.py b/django_downloadview/views/base.py index 00bb261..43869df 100644 --- a/django_downloadview/views/base.py +++ b/django_downloadview/views/base.py @@ -66,22 +66,20 @@ class DownloadMixin(object): else: return was_modified_since(since, modification_time, size) - def not_modified_response(self, *args, **kwargs): - """Return :py:class:`django.http.HttpResponseNotModified` instance.""" - content_type = self.file_instance.content_type - return HttpResponseNotModified(content_type=content_type) + def not_modified_response(self, **response_kwargs): + """Return :class:`django.http.HttpResponseNotModified` instance.""" + return HttpResponseNotModified(**response_kwargs) - def download_response(self, *args, **kwargs): - """Return :py:class:`DownloadResponse` instance.""" - response_kwargs = {'file_instance': self.file_instance, - 'attachment': self.attachment, - 'basename': self.get_basename()} - response_kwargs.update(kwargs) + def download_response(self, **response_kwargs): + """Return :class:`~django_downloadview.response.DownloadResponse`.""" + response_kwargs.setdefault('file_instance', self.file_instance) + response_kwargs.setdefault('attachment', self.attachment) + response_kwargs.setdefault('basename', self.get_basename()) response = self.response_class(**response_kwargs) return response - def render_to_response(self, *args, **kwargs): - """Return a download response. + def render_to_response(self, **response_kwargs): + """Return "download" response. Respects the "HTTP_IF_MODIFIED_SINCE" header if any. In that case, uses :py:meth:`was_modified_since` and :py:meth:`not_modified_response`. @@ -94,9 +92,9 @@ class DownloadMixin(object): since = self.request.META.get('HTTP_IF_MODIFIED_SINCE', None) if since is not None: if not self.was_modified_since(self.file_instance, since): - return self.not_modified_response(*args, **kwargs) + return self.not_modified_response(**response_kwargs) # Return download response. - return self.download_response(*args, **kwargs) + return self.download_response(**response_kwargs) class BaseDownloadView(DownloadMixin, View): From 0371f84f26f7cb2518a37d9b0d2909e62c03df3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Tue, 5 Nov 2013 07:29:58 +0100 Subject: [PATCH 22/25] Added unit tests around BaseDownloadView. Introduced setup_view() test utility. --- django_downloadview/tests/__init__.py | 16 ++++++++++++++++ django_downloadview/tests/views.py | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/django_downloadview/tests/__init__.py b/django_downloadview/tests/__init__.py index 0f38c3e..c8896e3 100644 --- a/django_downloadview/tests/__init__.py +++ b/django_downloadview/tests/__init__.py @@ -1,2 +1,18 @@ # -*- coding: utf-8 -*- """Unit tests.""" + + +def setup_view(view, request, *args, **kwargs): + """Mimic as_view() returned callable, but returns view instance. + + ``args`` and ``kwargs`` are the same you would pass to + :func:`~django.core.urlresolvers.reverse`. + + This is an early implementation of + https://code.djangoproject.com/ticket/20456 + + """ + view.request = request + view.args = args + view.kwargs = kwargs + return view diff --git a/django_downloadview/tests/views.py b/django_downloadview/tests/views.py index 6399f8c..12238ee 100644 --- a/django_downloadview/tests/views.py +++ b/django_downloadview/tests/views.py @@ -162,3 +162,18 @@ class DownloadMixinTestCase(unittest.TestCase): mixin.get_file.assert_called_once_with() self.assertEqual(mixin.was_modified_since.call_count, 0) mixin.download_response.assert_called_once_with() + + +class BaseDownloadViewTestCase(unittest.TestCase): + "Tests around :class:`django_downloadviews.views.base.BaseDownloadView`." + def test_get(self): + """BaseDownloadView.get() calls render_to_response().""" + request = django.test.RequestFactory().get('/dummy-url') + args = ['dummy-arg'] + kwargs = {'dummy': 'kwarg'} + view = setup_view(base.BaseDownloadView(), request, *args, **kwargs) + view.render_to_response = mock.Mock( + return_value=mock.sentinel.response) + response = view.get(request, *args, **kwargs) + self.assertIs(response, mock.sentinel.response) + view.render_to_response.assert_called_once_with() From 2c1ad3c7302dfda3c64f0f8500d257e27b949c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Tue, 5 Nov 2013 11:44:48 +0100 Subject: [PATCH 23/25] ObjectDownloadView now inherits from SingleObjectMixin and BaseDownloadView. Added example for setup_view() in documentation. --- Makefile | 7 ++-- demo/demoproject/settings.py | 20 +++-------- demo/demoproject/storage/tests.py | 48 +++++++++++++++++++++------ django_downloadview/api.py | 1 + django_downloadview/test.py | 28 ++++++++++++++++ django_downloadview/tests/__init__.py | 16 --------- django_downloadview/tests/api.py | 1 + django_downloadview/tests/views.py | 2 +- django_downloadview/views/base.py | 12 +++---- django_downloadview/views/object.py | 10 ++++-- docs/testing.txt | 35 ++++++++++++------- etc/buildout.cfg | 4 +-- etc/{nose.cfg => nose/base.cfg} | 2 ++ etc/nose/demoproject.cfg | 4 +++ etc/nose/django_downloadview.cfg | 4 +++ 15 files changed, 126 insertions(+), 68 deletions(-) rename etc/{nose.cfg => nose/base.cfg} (74%) create mode 100644 etc/nose/demoproject.cfg create mode 100644 etc/nose/django_downloadview.cfg diff --git a/Makefile b/Makefile index f7386d1..242723b 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ DATA_DIR = $(ROOT_DIR)/var WGET = wget PYTHON = $(shell which python) PROJECT = $(shell $(PYTHON) -c "import setup; print setup.NAME") +PACKAGE = $(shell $(PYTHON) -c "import setup; print setup.PACKAGES[0]") BUILDOUT_CFG = $(ROOT_DIR)/etc/buildout.cfg BUILDOUT_DIR = $(ROOT_DIR)/lib/buildout BUILDOUT_VERSION = 1.7.0 @@ -51,17 +52,17 @@ test: test-app test-demo test-documentation test-app: - $(NOSE) -c $(ROOT_DIR)/etc/nose.cfg --with-coverage --cover-package=django_downloadview django_downloadview tests + $(NOSE) -c $(ROOT_DIR)/etc/nose/base.cfg -c $(ROOT_DIR)/etc/nose/$(PACKAGE).cfg mv $(ROOT_DIR)/.coverage $(ROOT_DIR)/var/test/app.coverage test-demo: - $(BIN_DIR)/demo test demo + $(BIN_DIR)/demo test --nose-verbosity=2 mv $(ROOT_DIR)/.coverage $(ROOT_DIR)/var/test/demo.coverage test-documentation: - $(NOSE) -c $(ROOT_DIR)/etc/nose.cfg sphinxcontrib.testbuild.tests + $(NOSE) -c $(ROOT_DIR)/etc/nose/base.cfg sphinxcontrib.testbuild.tests sphinx: diff --git a/demo/demoproject/settings.py b/demo/demoproject/settings.py index 56742e1..86e6110 100755 --- a/demo/demoproject/settings.py +++ b/demo/demoproject/settings.py @@ -8,6 +8,7 @@ demoproject_dir = dirname(abspath(__file__)) demo_dir = dirname(demoproject_dir) root_dir = dirname(demo_dir) data_dir = join(root_dir, 'var') +cfg_dir = join(root_dir, 'etc') # Mandatory settings. @@ -76,21 +77,10 @@ DOWNLOADVIEW_MIDDLEWARES = ( ) -# Development configuration. DEBUG = True TEMPLATE_DEBUG = DEBUG TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' -NOSE_ARGS = ['--verbose', - '--nocapture', - '--rednose', - '--with-id', # allows --failed which only reruns failed tests - '--id-file=%s' % join(data_dir, 'test', 'noseids'), - '--with-doctest', - '--with-xunit', - '--xunit-file=%s' % join(data_dir, 'test', 'nosetests.xml'), - '--with-coverage', - '--cover-erase', - '--cover-package=django_downloadview', - '--no-path-adjustment', - '--all-modules', - ] +nose_cfg_dir = join(cfg_dir, 'nose') +NOSE_ARGS = ['--config={etc}/base.cfg'.format(etc=nose_cfg_dir), + '--config={etc}/{package}.cfg'.format(etc=nose_cfg_dir, + package=__package__)] diff --git a/demo/demoproject/storage/tests.py b/demo/demoproject/storage/tests.py index 4bcb064..1924f48 100644 --- a/demo/demoproject/storage/tests.py +++ b/demo/demoproject/storage/tests.py @@ -1,15 +1,13 @@ -try: - from unittest import mock -except ImportError: # Python 2.x fallback. - import mock +import unittest from django.core.files.base import ContentFile from django.core.urlresolvers import reverse import django.test -from django_downloadview import temporary_media_root, assert_download_response +from django_downloadview import assert_download_response, temporary_media_root +from django_downloadview import setup_view -from demoproject.storage.views import storage +from demoproject.storage import views # Fixtures. @@ -17,13 +15,13 @@ file_content = 'Hello world!\n' def setup_file(path): - storage.save(path, ContentFile(file_content)) + views.storage.save(path, ContentFile(file_content)) class StaticPathTestCase(django.test.TestCase): @temporary_media_root() def test_download_response(self): - """'static_path' streams file by path.""" + """'storage:static_path' streams file by path.""" setup_file('1.txt') url = reverse('storage:static_path', kwargs={'path': '1.txt'}) response = self.client.get(url) @@ -34,10 +32,20 @@ class StaticPathTestCase(django.test.TestCase): mime_type='text/plain') -class DynamicPathTestCase(django.test.TestCase): +class DynamicPathIntegrationTestCase(django.test.TestCase): + """Integration tests around ``storage:dynamic_path`` URL.""" @temporary_media_root() def test_download_response(self): - """'dynamic_path' streams file by generated path.""" + """'dynamic_path' streams file by generated path. + + As we use ``self.client``, this test involves the whole Django stack, + including settings, middlewares, decorators... So we need to setup a + file, the storage, and an URL. + + This test actually asserts the URL ``storage:dynamic_path`` streams a + file in storage. + + """ setup_file('1.TXT') url = reverse('storage:dynamic_path', kwargs={'path': '1.txt'}) response = self.client.get(url) @@ -46,3 +54,23 @@ class DynamicPathTestCase(django.test.TestCase): content=file_content, basename='1.TXT', mime_type='text/plain') + + +class DynamicPathUnitTestCase(unittest.TestCase): + """Unit tests around ``views.DynamicStorageDownloadView``.""" + def test_get_path(self): + """DynamicStorageDownloadView.get_path() returns uppercase path. + + Uses :func:`~django_downloadview.test.setup_view` to target only + overriden methods. + + This test does not involve URLconf, middlewares or decorators. It is + fast. It has clear scope. It does not assert ``storage:dynamic_path`` + URL works. It targets only custom ``DynamicStorageDownloadView`` class. + + """ + view = setup_view(views.DynamicStorageDownloadView(), + django.test.RequestFactory().get('/fake-url'), + path='dummy path') + path = view.get_path() + self.assertEqual(path, 'DUMMY PATH') diff --git a/django_downloadview/api.py b/django_downloadview/api.py index 2167762..5428535 100644 --- a/django_downloadview/api.py +++ b/django_downloadview/api.py @@ -18,4 +18,5 @@ from django_downloadview.views import (PathDownloadView, # NoQA DownloadMixin) from django_downloadview.sendfile import sendfile # NoQA from django_downloadview.test import (assert_download_response, # NoQA + setup_view, temporary_media_root) diff --git a/django_downloadview/test.py b/django_downloadview/test.py index 3b0cd2d..31482b3 100644 --- a/django_downloadview/test.py +++ b/django_downloadview/test.py @@ -8,6 +8,34 @@ from django.test.utils import override_settings from django_downloadview.middlewares import is_download_response +def setup_view(view, request, *args, **kwargs): + """Mimic ``as_view()``, but returns view instance. + + Use this function to get view instances on which you can run unit tests, + by testing specific methods. + + This is an early implementation of + https://code.djangoproject.com/ticket/20456 + + ``view`` + A view instance, such as ``TemplateView(template_name='dummy.html')``. + Initialization arguments are the same you would pass to ``as_view()``. + + ``request`` + A request object, typically built with + :class:`~django.test.client.RequestFactory`. + + ``args`` and ``kwargs`` + "URLconf" positional and keyword arguments, the same you would pass to + :func:`~django.core.urlresolvers.reverse`. + + """ + view.request = request + view.args = args + view.kwargs = kwargs + return view + + class temporary_media_root(override_settings): """Temporarily override settings.MEDIA_ROOT with a temporary directory. diff --git a/django_downloadview/tests/__init__.py b/django_downloadview/tests/__init__.py index c8896e3..0f38c3e 100644 --- a/django_downloadview/tests/__init__.py +++ b/django_downloadview/tests/__init__.py @@ -1,18 +1,2 @@ # -*- coding: utf-8 -*- """Unit tests.""" - - -def setup_view(view, request, *args, **kwargs): - """Mimic as_view() returned callable, but returns view instance. - - ``args`` and ``kwargs`` are the same you would pass to - :func:`~django.core.urlresolvers.reverse`. - - This is an early implementation of - https://code.djangoproject.com/ticket/20456 - - """ - view.request = request - view.args = args - view.kwargs = kwargs - return view diff --git a/django_downloadview/tests/api.py b/django_downloadview/tests/api.py index 5adc0d7..40bdbeb 100644 --- a/django_downloadview/tests/api.py +++ b/django_downloadview/tests/api.py @@ -54,6 +54,7 @@ class APITestCase(unittest.TestCase): 'DownloadDispatcherMiddleware', # Testing: 'assert_download_response', + 'setup_view', 'temporary_media_root', # Utilities: 'StringIteratorIO', diff --git a/django_downloadview/tests/views.py b/django_downloadview/tests/views.py index 12238ee..c539d16 100644 --- a/django_downloadview/tests/views.py +++ b/django_downloadview/tests/views.py @@ -9,7 +9,7 @@ except ImportError: from django.http.response import HttpResponseNotModified import django.test -from django_downloadview.tests import setup_view +from django_downloadview.test import setup_view from django_downloadview.views import base diff --git a/django_downloadview/views/base.py b/django_downloadview/views/base.py index 43869df..807c0c9 100644 --- a/django_downloadview/views/base.py +++ b/django_downloadview/views/base.py @@ -66,19 +66,19 @@ class DownloadMixin(object): else: return was_modified_since(since, modification_time, size) - def not_modified_response(self, **response_kwargs): + def not_modified_response(self, *response_args, **response_kwargs): """Return :class:`django.http.HttpResponseNotModified` instance.""" - return HttpResponseNotModified(**response_kwargs) + return HttpResponseNotModified(*response_args, **response_kwargs) - def download_response(self, **response_kwargs): + def download_response(self, *response_args, **response_kwargs): """Return :class:`~django_downloadview.response.DownloadResponse`.""" response_kwargs.setdefault('file_instance', self.file_instance) response_kwargs.setdefault('attachment', self.attachment) response_kwargs.setdefault('basename', self.get_basename()) - response = self.response_class(**response_kwargs) + response = self.response_class(*response_args, **response_kwargs) return response - def render_to_response(self, **response_kwargs): + def render_to_response(self, *response_args, **response_kwargs): """Return "download" response. Respects the "HTTP_IF_MODIFIED_SINCE" header if any. In that case, uses @@ -94,7 +94,7 @@ class DownloadMixin(object): if not self.was_modified_since(self.file_instance, since): return self.not_modified_response(**response_kwargs) # Return download response. - return self.download_response(**response_kwargs) + return self.download_response(*response_args, **response_kwargs) class BaseDownloadView(DownloadMixin, View): diff --git a/django_downloadview/views/object.py b/django_downloadview/views/object.py index f590d2c..43f3127 100644 --- a/django_downloadview/views/object.py +++ b/django_downloadview/views/object.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- """Stream files that live in models.""" -from django.views.generic.detail import BaseDetailView +from django.views.generic.detail import SingleObjectMixin -from django_downloadview.views.base import DownloadMixin +from django_downloadview.views.base import BaseDownloadView -class ObjectDownloadView(DownloadMixin, BaseDetailView): +class ObjectDownloadView(SingleObjectMixin, BaseDownloadView): """Serve file fields from models. This class extends BaseDetailView, so you can use its arguments to target @@ -76,3 +76,7 @@ class ObjectDownloadView(DownloadMixin, BaseDetailView): if model_field: basename = getattr(self.object, model_field) return basename + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + return super(ObjectDownloadView, self).get(request, *args, **kwargs) diff --git a/docs/testing.txt b/docs/testing.txt index 7aebe3a..5be8a2b 100644 --- a/docs/testing.txt +++ b/docs/testing.txt @@ -4,18 +4,12 @@ Write tests `django_downloadview` embeds test utilities: -* :func:`~django_downloadview.test.assert_download_response` * :func:`~django_downloadview.test.temporary_media_root` +* :func:`~django_downloadview.test.assert_download_response` +* :func:`~django_downloadview.test.setup_view` * :func:`~django_downloadview.nginx.tests.assert_x_accel_redirect` -************************ -assert_download_response -************************ - -.. autofunction:: django_downloadview.test.assert_download_response - - ******************** temporary_media_root ******************** @@ -23,11 +17,28 @@ temporary_media_root .. autofunction:: django_downloadview.test.temporary_media_root -******* -Example -******* +************************ +assert_download_response +************************ -Here are the tests related to :doc:`StorageDownloadView demo `: +.. autofunction:: django_downloadview.test.assert_download_response + +Examples, related to :doc:`StorageDownloadView demo `: .. literalinclude:: /../demo/demoproject/storage/tests.py :language: python + :lines: 3-7, 9-57 + + +********** +setup_view +********** + +.. autofunction:: django_downloadview.test.setup_view + +Example, related to :doc:`StorageDownloadView demo `: + +.. literalinclude:: /../demo/demoproject/storage/tests.py + :language: python + :lines: 1-2, 8-12, 59- + diff --git a/etc/buildout.cfg b/etc/buildout.cfg index 52d19e6..1281e0a 100644 --- a/etc/buildout.cfg +++ b/etc/buildout.cfg @@ -70,12 +70,12 @@ collective.recipe.omelette = 0.16 coverage = 3.6 distribute = 0.6.34 Django = 1.5 -django-nose = 1.1 +django-nose = 1.2 docutils = 0.10 evg.recipe.activate = 0.5 Jinja2 = 2.6 mock = 1.0.1 -nose = 1.2.1 +nose = 1.3.0 Pygments = 1.6 python-termstyle = 0.1.10 rednose = 0.3 diff --git a/etc/nose.cfg b/etc/nose/base.cfg similarity index 74% rename from etc/nose.cfg rename to etc/nose/base.cfg index b43ad26..8165c25 100644 --- a/etc/nose.cfg +++ b/etc/nose/base.cfg @@ -5,3 +5,5 @@ with-doctest = True rednose = True no-path-adjustment = True all-modules = True +cover-inclusive = True +cover-tests = True diff --git a/etc/nose/demoproject.cfg b/etc/nose/demoproject.cfg new file mode 100644 index 0000000..d77cfcf --- /dev/null +++ b/etc/nose/demoproject.cfg @@ -0,0 +1,4 @@ +[nosetests] +with-coverage = True +cover-package = demoproject +tests = demoproject diff --git a/etc/nose/django_downloadview.cfg b/etc/nose/django_downloadview.cfg new file mode 100644 index 0000000..70e3c08 --- /dev/null +++ b/etc/nose/django_downloadview.cfg @@ -0,0 +1,4 @@ +[nosetests] +with-coverage = True +cover-package = django_downloadview +tests = django_downloadview,tests From 6a4f42189211fecb9c8829d527949bb42641689c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Tue, 5 Nov 2013 11:53:29 +0100 Subject: [PATCH 24/25] Dropped support (tests) for Python 2.6. --- .travis.yml | 1 - CHANGELOG | 56 ++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4a008ad..9d1a631 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python python: - - "2.6" - "2.7" install: make configure develop script: make test diff --git a/CHANGELOG b/CHANGELOG index fc6b5f9..70e669c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,25 +4,63 @@ Changelog 1.3 (unreleased) ---------------- -Big refactoring around Nginx middleware. +Big refactoring around middleware configuration, API readability and +documentation. + +- Bugfix #44 - Introduced ``django_downloadview.File``, which patches + ``django.core.files.base.File.__iter__()`` implementation. + See https://code.djangoproject.com/ticket/21321 + +- Bugfix #?? - Fixed ``basename`` assertion in + :func:`~django_downloadview.test.assert_download_response`: always use + ``Content-Disposition`` header. + +- Bugfix #?? - Fixed ``content`` assertion in + :func:`~django_downloadview.test.assert_download_response`: use only + ``streaming_content`` attribute, whatever the file wrapper. + +- Feature #?? - Introduced + :class:`~django_downloadview.middlewares.DownloadDispatcherMiddleware` that + iterates over a list of configurable download middlewares. Allows to plug + several download middlewares with different configurations. Deprecates the + settings related to previous single-and-global middleware. + +- Refactoring #?? - Dropped support of Python 2.6 + +- Refactoring #?? - ObjectDownloadView now inherits from SingleObjectMixin and + BaseDownloadView (was DownloadMixin and BaseDetailView). + +- Refactoring #?? - Documentation includes examples from demo project. + +- Refactoring #?? - Documentation focuses on usage, rather than API. + +- Refactoring #?? - Added base classes in + :mod:`django_downloadview.middlewares`, such as + :class:`~django_downloadview.middlewares.ProxiedDownloadMiddleware`. -- Feature #?? - Introduced DownloadDispatcherMiddleware that iterates over a - list of configurable download middlewares. Allows to plug several download - middlewares with different configurations. Deprecates the settings related - to previous single-and-global middleware. - Refactoring #?? - Splitted nginx module in a package. + - Refactoring #?? - Expose most Python API directly in `django_downloadview` - package. Simplifies imports in client applications. + package. Simplifies ``import`` statements in client applications. + +- Refactoring #?? - Simplified DownloadMixin.render_to_response() signature. + +- Added unit tests, improved code coverage. + 1.2 (2013-05-28) ---------------- Bugfixes and documentation improvements. -- Bug #26 - Prevented computation of virtual file's size, unless the file +- Bugfix #26 - Prevented computation of virtual file's size, unless the file wrapper implements was_modified_since() method. -- Bug #34 - Improved support of files that do not implement modification time. -- Bug #35 - Fixed README conversion from reStructuredText to HTML (PyPI). + +- Bugfix #34 - Improved support of files that do not implement modification + time. + +- Bugfix #35 - Fixed README conversion from reStructuredText to HTML (PyPI). + 1.1 (2013-04-11) ---------------- From 970820c5e27d12156bdb680757d3709550f3a51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Bryon?= Date: Wed, 6 Nov 2013 01:46:55 +0100 Subject: [PATCH 25/25] Updated CHANGELOG. --- CHANGELOG | 59 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 70e669c..556abe1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,10 @@ Changelog ========= +This document describes changes between past releases. For information about +future releases, check `milestones`_ and :doc:`/about/vision`. + + 1.3 (unreleased) ---------------- @@ -11,39 +15,47 @@ documentation. ``django.core.files.base.File.__iter__()`` implementation. See https://code.djangoproject.com/ticket/21321 -- Bugfix #?? - Fixed ``basename`` assertion in - :func:`~django_downloadview.test.assert_download_response`: always use - ``Content-Disposition`` header. +- Bugfix #48 - Fixed ``basename`` assertion in ``assert_download_response``: + checks ``Content-Disposition`` header. -- Bugfix #?? - Fixed ``content`` assertion in - :func:`~django_downloadview.test.assert_download_response`: use only - ``streaming_content`` attribute, whatever the file wrapper. +- Bugfix #49 - Fixed ``content`` assertion in ``assert_download_response``: + checks only response's ``streaming_content`` attribute. -- Feature #?? - Introduced +- Feature #50 - Introduced :class:`~django_downloadview.middlewares.DownloadDispatcherMiddleware` that iterates over a list of configurable download middlewares. Allows to plug - several download middlewares with different configurations. Deprecates the - settings related to previous single-and-global middleware. + several download middlewares with different configurations. -- Refactoring #?? - Dropped support of Python 2.6 + Deprecates the following settings related to previous single-and-global + middleware: -- Refactoring #?? - ObjectDownloadView now inherits from SingleObjectMixin and + * ``NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT`` + * ``NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL`` + * ``NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES`` + * ``NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING`` + * ``NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE`` + +- Feature #42 - Documentation shows how to stream generated content (yield). + Introduced ``django_downloadview.StringIteratorIO``. + +- Refactoring #51 - Dropped support of Python 2.6 + +- Refactoring #52 - ObjectDownloadView now inherits from SingleObjectMixin and BaseDownloadView (was DownloadMixin and BaseDetailView). + Simplified DownloadMixin.render_to_response() signature. -- Refactoring #?? - Documentation includes examples from demo project. +- Refactoring #40 - Documentation includes examples from demo project. -- Refactoring #?? - Documentation focuses on usage, rather than API. +- Refactoring #39 - Documentation focuses on usage, rather than API. Improved + narrative documentation. -- Refactoring #?? - Added base classes in - :mod:`django_downloadview.middlewares`, such as - :class:`~django_downloadview.middlewares.ProxiedDownloadMiddleware`. +- Refactoring #53 - Added base classes in ``django_downloadview.middlewares``, + such as ``ProxiedDownloadMiddleware``. -- Refactoring #?? - Splitted nginx module in a package. -- Refactoring #?? - Expose most Python API directly in `django_downloadview` +- Refactoring #54 - Expose most Python API directly in `django_downloadview` package. Simplifies ``import`` statements in client applications. - -- Refactoring #?? - Simplified DownloadMixin.render_to_response() signature. + Splitted nginx module in a package. - Added unit tests, improved code coverage. @@ -90,3 +102,10 @@ Contains **backward incompatible changes.** - Introduced optimizations for Nginx X-Accel: a middleware and a decorator - Introduced generic views: DownloadView and ObjectDownloadView - Initialized project + + +.. rubric:: Notes & references + +.. target-notes:: + +.. _`milestones`: https://github.com/benoitbryon/django-downloadview/issues/milestones