Merge pull request #55 from benoitbryon/api-readability

Closes #54, closes #53, closes #52, closes #51, closes #50, closes #49, closes #48, closes #42, refs #44, closes #40, closes #39 - Merge api-readability branch which contains almost all 1.3 release.
This commit is contained in:
Benoît Bryon 2013-11-05 16:52:18 -08:00
commit e900c1a253
113 changed files with 3445 additions and 1716 deletions

1
.gitignore vendored
View file

@ -14,3 +14,4 @@
# Editors' temporary buffers.
.*.swp
*~

View file

@ -1,6 +1,5 @@
language: python
python:
- "2.6"
- "2.7"
install: make configure develop
script: make test

View file

@ -9,5 +9,7 @@ Original code by `Novapost <http://www.novapost.fr>`_ team:
* Nicolas Tobo <https://github.com/nicolastobo>
* Lauréline Guérin <https://github.com/zebuline>
* Gregory Tappero <https://github.com/coulix>
* Rémy Hubscher <remy.hubscher@novapost.fr>
* Benoît Bryon <benoit@marmelune.net>
* Rémy Hubscher <remy.hubscher@novapost.fr>
Developers: https://github.com/benoitbryon/django-downloadview/graphs/contributors

View file

@ -1,10 +1,63 @@
Changelog
=========
This document describes changes between past releases. For information about
future releases, check `milestones`_ and :doc:`/about/vision`.
1.3 (unreleased)
----------------
- Nothing changed yet.
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 #48 - Fixed ``basename`` assertion in ``assert_download_response``:
checks ``Content-Disposition`` header.
- Bugfix #49 - Fixed ``content`` assertion in ``assert_download_response``:
checks only response's ``streaming_content`` attribute.
- 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 following settings related to previous single-and-global
middleware:
* ``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 #40 - Documentation includes examples from demo project.
- Refactoring #39 - Documentation focuses on usage, rather than API. Improved
narrative documentation.
- Refactoring #53 - Added base classes in ``django_downloadview.middlewares``,
such as ``ProxiedDownloadMiddleware``.
- Refactoring #54 - Expose most Python API directly in `django_downloadview`
package. Simplifies ``import`` statements in client applications.
Splitted nginx module in a package.
- Added unit tests, improved code coverage.
1.2 (2013-05-28)
@ -12,10 +65,14 @@ Changelog
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)
----------------
@ -45,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

32
INSTALL
View file

@ -1,30 +1,32 @@
############
Installation
############
#######
Install
#######
This project is open-source, published under BSD license.
See :doc:`/about/license` for details.
.. note::
If you want to install a development environment, you should go to :doc:`/dev`
documentation.
If you want to install a development environment, please see :doc:`/dev`.
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
.. note::
Since version 1.1, django-downloadview requires Django>=1.5, which provides
StreamingHttpResponse.
:py:class:`~django.http.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
<demo>` for examples, and :doc:`API documentation <api/django_downloadview>`.
.. rubric:: Notes & references
Optionally, you may setup additional :doc:`server optimizations
<optimizations/index>`.
.. seealso::
* :doc:`/settings`
* :doc:`/about/changelog`
* :doc:`/about/license`

View file

@ -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

View file

@ -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,38 +52,36 @@ 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
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
$(NOSE) -c $(ROOT_DIR)/etc/nose/base.cfg sphinxcontrib.testbuild.tests
sphinx:
make --directory=docs clean html doctest
documentation: apidoc sphinx
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

61
README
View file

@ -1,61 +0,0 @@
###################
django-downloadview
###################
Django-DownloadView provides generic views to make Django serve files.
It can 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.
*******
Example
*******
In some ``urls.py``, serve files managed in a model:
.. code-block:: python
from django.conf.urls import url, url_patterns
from django_downloadview import ObjectDownloadView
from demoproject.download.models import Document # A model with a FileField
# ObjectDownloadView inherits from django.views.generic.BaseDetailView.
download = ObjectDownloadView.as_view(model=Document, file_field='file')
url_patterns = ('',
url('^download/(?P<slug>[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
**********
* Documentation: http://django-downloadview.readthedocs.org
* PyPI page: http://pypi.python.org/pypi/django-downloadview
* 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

47
README.rst Normal file
View file

@ -0,0 +1,47 @@
###################
django-downloadview
###################
``django-downloadview`` makes it easy to serve files with Django:
* you manage files with Django (permissions, search, generation, ...);
* 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
*******
Let's serve a file stored in a FileField of some model:
.. code:: python
from django.conf.urls import url, url_patterns
from django_downloadview import ObjectDownloadView
from demoproject.download.models import Document # A model with a FileField
# ObjectDownloadView inherits from django.views.generic.BaseDetailView.
download = ObjectDownloadView.as_view(model=Document, file_field='file')
url_patterns = ('',
url('^download/(?P<slug>[A-Za-z0-9_-]+)/$', download, name='download'),
)
**********
Ressources
**********
* Documentation: http://django-downloadview.readthedocs.org
* PyPI page: http://pypi.python.org/pypi/django-downloadview
* 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

View file

@ -1,98 +0,0 @@
############
Demo project
############
The :file:`demo/` folder holds a demo project to illustrate django-downloadview
usage.
***********************
Browse demo code online
***********************
See `demo folder in project's repository`_.
***************
Deploy the demo
***************
System requirements:
* `Python`_ version 2.6 or 2.7, available as ``python`` command.
.. note::
You may use `Virtualenv`_ to make sure the active ``python`` is the right
one.
* ``make`` and ``wget`` to use the provided :file:`Makefile`.
Execute:
.. code-block:: sh
git clone git@github.com:benoitbryon/django-downloadview.git
cd django-downloadview/
make demo
It installs and runs the demo server on localhost, port 8000. So have a look
at http://localhost:8000/
.. note::
If you cannot execute the Makefile, read it and adapt the few commands it
contains to your needs.
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
**********
.. target-notes::
.. _`demo folder in project's repository`:
https://github.com/benoitbryon/django-downloadview/tree/master/demo/demoproject/
.. _`Python`: http://python.org
.. _`Virtualenv`: http://virtualenv.org

72
demo/README.rst Normal file
View file

@ -0,0 +1,72 @@
############
Demo project
############
`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 the 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!
***********************
Browse demo code online
***********************
See `demo folder in project's repository`_.
***************
Deploy the demo
***************
System requirements:
* `Python`_ version 2.7, available as ``python`` command.
.. note::
You may use `Virtualenv`_ to make sure the active ``python`` is the right
one.
* ``make`` and ``wget`` to use the provided :file:`Makefile`.
Execute:
.. code-block:: sh
git clone git@github.com:benoitbryon/django-downloadview.git
cd django-downloadview/
make runserver
It installs and runs the demo server on localhost, port 8000. So have a look
at http://localhost:8000/
.. note::
If you cannot execute the Makefile, read it and adapt the few commands it
contains to your needs.
Browse and use :file:`demo/demoproject/` as a sandbox.
**********
References
**********
.. target-notes::
.. _`demo folder in project's repository`:
https://github.com/benoitbryon/django-downloadview/tree/master/demo/demoproject/
.. _`Python`: http://python.org
.. _`Virtualenv`: http://virtualenv.org

View file

@ -1 +0,0 @@

View file

@ -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')

View file

@ -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)

View file

@ -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<slug>[a-zA-Z0-9_-]+)/$',
'download_document',
name='document'),
# Storage-based downloads.
url(r'^storage/(?P<path>[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<path>[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'),
)

View file

@ -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'https://raw.github.com/benoitbryon/django-downloadview/master/demo/demoproject/download/fixtures/hello-world.txt',
basename=u'hello-world.txt')

View file

@ -1,7 +1,7 @@
[
{
"pk": 1,
"model": "download.document",
"model": "object.document",
"fields": {
"slug": "hello-world",
"file": "document/hello-world.txt"

View file

@ -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.
"""

View file

@ -0,0 +1 @@
"""Required to make a Django application."""

View file

@ -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')

View file

@ -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'),
)

View file

@ -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()

View file

@ -1 +1 @@
"""Nginx optimizations applied to demoproject.download."""
"""Nginx optimizations."""

View file

@ -0,0 +1 @@
"""Required to make a Django application."""

View file

@ -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)

View file

@ -4,9 +4,10 @@ from django.conf.urls import patterns, url
urlpatterns = patterns(
'demoproject.nginx.views',
url(r'^document-nginx/(?P<slug>[a-zA-Z0-9_-]+)/$',
'download_document_nginx', name='download_document_nginx'),
url(r'^document-nginx-inline/(?P<slug>[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'),
)

View file

@ -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/')

View file

@ -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.
"""

View file

@ -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)

View file

@ -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')

View file

@ -0,0 +1,17 @@
from django.conf.urls import patterns, url
from demoproject.object import views
urlpatterns = patterns(
'',
url(r'^default-file/(?P<slug>[a-zA-Z0-9_-]+)/$',
views.default_file_view,
name='default_file'),
url(r'^another-file/(?P<slug>[a-zA-Z0-9_-]+)/$',
views.another_file_view,
name='another_file'),
url(r'^deserialized_basename/(?P<slug>[a-zA-Z0-9_-]+)/$',
views.deserialized_basename_view,
name='deserialized_basename'),
)

View file

@ -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')

View file

@ -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.
"""

View file

@ -0,0 +1 @@
"""Required to make a Django application."""

View file

@ -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')

View file

@ -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<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
views.dynamic_path,
name='dynamic_path'),
)

View file

@ -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()

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""Django settings for Django-DownloadView demo project."""
from os.path import abspath, dirname, join
@ -7,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.
@ -44,45 +46,41 @@ 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.nginx.XAccelRedirectMiddleware')
#NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT = MEDIA_ROOT
#NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL = "/proxied-download"
DOWNLOADVIEW_MIDDLEWARES = (
('default', 'django_downloadview.nginx.XAccelRedirectMiddleware',
{'source_url': '/media/nginx/',
'destination_url': '/nginx-optimized-by-middleware/'}),
)
# 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__)]

View file

@ -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.
"""

View file

@ -0,0 +1 @@
"""Required to make a Django application."""

View file

@ -0,0 +1,4 @@
from django.core.files.storage import FileSystemStorage
storage = FileSystemStorage()

View file

@ -0,0 +1,76 @@
import unittest
from django.core.files.base import ContentFile
from django.core.urlresolvers import reverse
import django.test
from django_downloadview import assert_download_response, temporary_media_root
from django_downloadview import setup_view
from demoproject.storage import views
# Fixtures.
file_content = 'Hello world!\n'
def setup_file(path):
views.storage.save(path, ContentFile(file_content))
class StaticPathTestCase(django.test.TestCase):
@temporary_media_root()
def test_download_response(self):
"""'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)
assert_download_response(self,
response,
content=file_content,
basename='1.txt',
mime_type='text/plain')
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.
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)
assert_download_response(self,
response,
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')

View file

@ -0,0 +1,14 @@
from django.conf.urls import patterns, url
from demoproject.storage import views
urlpatterns = patterns(
'',
url(r'^static-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
views.static_path,
name='static_path'),
url(r'^dynamic-path/(?P<path>[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,4})$',
views.dynamic_path,
name='dynamic_path'),
)

View file

@ -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)

View file

@ -10,19 +10,7 @@
<h2>Serving files with Django</h2>
<p>In the following views, Django streams the files, no optimization
has been setup.</p>
<ul>
<li><a href="{% url 'hello_world' %}">PathDownloadView</a></li>
<li><a href="{% url 'fixture_from_path' 'hello-world.txt' %}">
PathDownloadView + argument in URL
</a></li>
<li><a href="{% url 'fixture_from_storage' 'hello-world.txt' %}">
StorageDownloadView + path in URL
</a></li>
<li><a href="{% url 'document' 'hello-world' %}">
ObjectDownloadView
</a></li>
<li><a href="{% url 'http_hello_world' %}">
HTTPDownloadView</a>, a simple HTTP proxy</li>
<ul>
</ul>
<h2>Optimized downloads</h2>
@ -31,9 +19,6 @@
<p>Since nginx and other servers aren't installed on the demo, you
will get raw "X-Sendfile" responses. Look at the headers!</p>
<ul>
<li><a href="{% url 'download_document_nginx' 'hello-world' %}">
ObjectDownloadView (nginx)
</a></li>
</ul>
</body>
</html>

View file

@ -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')
)

View file

@ -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.
"""

View file

@ -0,0 +1 @@
"""Required to make a Django application."""

View file

@ -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')

View file

@ -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'),
)

View file

@ -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')

View file

@ -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)

View file

@ -1,13 +1,12 @@
"""django-downloadview provides generic download views for Django."""
# Shortcut import.
from django_downloadview.views import (PathDownloadView,
ObjectDownloadView,
StorageDownloadView,
VirtualDownloadView)
# -*- coding: utf-8 -*-
"""Serve files with Django and reverse proxies."""
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.api import * # NoQA

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
"""Declaration of API shortcuts."""
from django_downloadview.io import StringIteratorIO # NoQA
from django_downloadview.files import (StorageFile, # NoQA
VirtualFile,
HTTPFile,
File)
from django_downloadview.response import (DownloadResponse, # NoQA
ProxiedDownloadResponse)
from django_downloadview.middlewares import (BaseDownloadMiddleware, # NoQA
DownloadDispatcherMiddleware)
from django_downloadview.views import (PathDownloadView, # NoQA
ObjectDownloadView,
StorageDownloadView,
HTTPDownloadView,
VirtualDownloadView,
BaseDownloadView,
DownloadMixin)
from django_downloadview.sendfile import sendfile # NoQA
from django_downloadview.test import (assert_download_response, # NoQA
setup_view,
temporary_media_root)

View file

@ -1,17 +1,17 @@
"""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
: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.

View file

@ -1,9 +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.
@ -185,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

65
django_downloadview/io.py Normal file
View file

@ -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 <anacrolix@gmail.com> 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)

View file

@ -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,106 @@ 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 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):
"""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:
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:
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 NoRedirectionMatch(message)
return '/'.join((self.destination_url.rstrip('/'), url.lstrip('/')))

View file

@ -1,415 +0,0 @@
"""Optimizations for Nginx.
See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and
:doc:`narrative documentation about Nginx optimizations
</optimizations/nginx>`.
"""
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)

View file

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
"""Optimizations for Nginx.
See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and
:doc:`narrative documentation about Nginx optimizations
</optimizations/nginx>`.
"""
# 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)

View file

@ -0,0 +1,16 @@
# -*- 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
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)

View file

@ -0,0 +1,120 @@
import warnings
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django_downloadview.middlewares import (ProxiedDownloadMiddleware,
NoRedirectionMatch)
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."""
try:
redirect_url = self.get_redirect_url(response)
except NoRedirectionMatch:
return 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)

View file

@ -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')

View file

@ -0,0 +1,147 @@
# -*- 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.'.format(
deprecated=setting_name),
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.'.format(
deprecated=setting_name),
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.'.format(
deprecated=setting_name),
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.'.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.'.format(
deprecated=setting_name),
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.'.format(
deprecated=setting_name),
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.'.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.'.format(
deprecated=setting_name),
DeprecationWarning)
if not hasattr(settings, setting_name):
setattr(settings, setting_name, DEFAULT_DESTINATION_URL)

View file

@ -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)

View file

@ -1,48 +1,71 @@
"""HttpResponse subclasses."""
# -*- coding: utf-8 -*-
""":py:class:`django.http.HttpResponse` subclasses."""
import os
import mimetypes
from django.conf import settings
from django.http import StreamingHttpResponse
from django.http import HttpResponse, StreamingHttpResponse
class DownloadResponse(StreamingHttpResponse):
"""File download response.
"""File download response (Django serves file, client downloads it).
``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.
Constructor differs a bit from :class:`~django.http.response.HttpResponse`:
``file_instance``
A :doc:`file wrapper instance </files>`, 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
<https://github.com/django/django/blob/fd1279a44df3b9a837453cd79fd0fbcf81bae39d/django/core/handlers/wsgi.py#L268>`_.
* :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 </files>`. 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,
@ -69,7 +92,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()
@ -120,11 +146,11 @@ 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.nginx.response.XAccelRedirectResponse`.
"""
return isinstance(response, DownloadResponse)

View file

@ -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)

View file

@ -5,11 +5,43 @@ 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
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):
"""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 +52,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 +107,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 +119,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 +127,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 +135,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()

View file

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Unit tests."""

View file

@ -0,0 +1,117 @@
# -*- 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',
'setup_view',
'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)))

View file

@ -0,0 +1,179 @@
# -*- 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.test 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()
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()

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,104 @@
# -*- 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, *response_args, **response_kwargs):
"""Return :class:`django.http.HttpResponseNotModified` instance."""
return HttpResponseNotModified(*response_args, **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_args, **response_kwargs)
return response
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
: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(**response_kwargs)
# Return download response.
return self.download_response(*response_args, **response_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()

View file

@ -0,0 +1,46 @@
# -*- 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.
Default implementation returns :func:`requests.get` callable.
"""
return requests.get
def get_request_kwargs(self):
"""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).
Default implementation returns :attr:`url`.
"""
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())

View file

@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""Stream files that live in models."""
from django.views.generic.detail import SingleObjectMixin
from django_downloadview.views.base import BaseDownloadView
class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
"""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
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super(ObjectDownloadView, self).get(request, *args, **kwargs)

View file

@ -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()))

View file

@ -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())

View file

@ -0,0 +1,31 @@
# -*- 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.
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

View file

@ -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.
********************

View file

@ -4,6 +4,7 @@ About django-downloadview
.. toctree::
vision
alternatives
license
authors

26
docs/about/vision.txt Normal file
View file

@ -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
<https://github.com/benoitbryon/django-downloadview/issues/milestones>`_
.. target-notes::
.. _`Django`: https://django-project.com

View file

@ -1,75 +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:`nginx` Module
-------------------
.. automodule:: django_downloadview.nginx
: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:

View file

@ -1,9 +0,0 @@
###
API
###
Here is API documentation, generated from code.
.. toctree::
modules

View file

@ -1,7 +0,0 @@
django_downloadview
===================
.. toctree::
:maxdepth: 4
django_downloadview

View file

@ -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,15 @@ 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/'),
'requests': ('http://docs.python-requests.org/en/latest/', None),
}
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {

View file

@ -1 +1 @@
.. include:: ../demo/README
.. include:: ../demo/README.rst

View file

@ -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
*********************

89
docs/files.txt Normal file
View file

@ -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
</views/virtual>`.
****************************
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/

106
docs/healthchecks.txt Normal file
View file

@ -0,0 +1,106 @@
##################
Write healthchecks
##################
In the previous :doc:`testing </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

View file

@ -1,4 +1,4 @@
.. include:: ../README
.. include:: ../README.rst
********
@ -9,11 +9,15 @@ Contents
:maxdepth: 2
:titlesonly:
demo
overview
install
views
settings
views/index
optimizations/index
testing
api/index
healthchecks
files
responses
demo
about/index
dev

View file

@ -1,6 +1,6 @@
#############
Optimizations
#############
##################
Optimize streaming
##################
Some reverse proxies allow applications to delegate actual download to the
proxy:
@ -11,40 +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 doesn't contain file data.
View return some :class:`~django_downloadview.response.DownloadResponse`
instance, which itself carries a :doc:`file wrapper </files>`.
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.
`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
<django.template.response>`
.. 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/

View file

@ -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 </demo>`:
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 </demo>`.
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/<filename>``.
``/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/<filename>``.
.. warning::
Make sure Nginx can read the files to download! Check permissions.
*********************************************
Assert everything goes fine with healthchecks
*********************************************
:doc:`Healthchecks </healthchecks>` are the best way to check the complete
setup.
*************

109
docs/overview.txt Normal file
View file

@ -0,0 +1,109 @@
##################
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
************************************
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...
*************************************************
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/custom>`.
*****************************
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.
Learn more in :doc:`responses`.
***********************************
DownloadResponse carry file wrapper
***********************************
Views instanciate a :doc:`file wrapper </files>` and use it to initialize
responses.
**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`.
*****************************************************************
Middlewares convert DownloadResponse into ProxiedDownloadResponse
*****************************************************************
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`.
***************
Testing matters
***************
`django-downloadview` also helps you :doc:`test the views you customized
<testing>`.
You may also :doc:`write healthchecks </healthchecks>` to make sure everything
goes fine in live environments.
************
What's next?
************
Let's :doc:`install django-downloadview <install>`.
.. rubric:: Notes & references
.. target-notes::
.. _`nginx`: http://nginx.org
.. _`X-Accel`: http://wiki.nginx.org/X-accel

32
docs/responses.txt Normal file
View file

@ -0,0 +1,32 @@
#########
Responses
#########
.. currentmodule:: django_downloadview.response
Views return :class:`DownloadResponse`.
Middlewares (and decorators) are given the opportunity to capture responses and
convert them to :class:`ProxiedDownloadResponse`.
****************
DownloadResponse
****************
.. autoclass:: DownloadResponse
:members:
:undoc-members:
:show-inheritance:
:member-order: bysource
***********************
ProxiedDownloadResponse
***********************
.. autoclass:: ProxiedDownloadResponse
:members:
:undoc-members:
:show-inheritance:
:member-order: bysource

65
docs/settings.txt Normal file
View file

@ -0,0 +1,65 @@
#########
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
******************
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:
.. literalinclude:: /../demo/demoproject/settings.py
:language: python
:lines: 61-68
************************
DOWNLOADVIEW_MIDDLEWARES
************************
:default: []
If you plan to setup reverse-proxy :doc:`optimizations </optimizations/index>`,
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.
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
factory.
Example:
.. 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.

View file

@ -1,7 +1,44 @@
######################
Testing download views
######################
###########
Write tests
###########
This project includes shortcuts to simplify testing.
`django_downloadview` embeds test utilities:
* :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`
********************
temporary_media_root
********************
.. autofunction:: django_downloadview.test.temporary_media_root
************************
assert_download_response
************************
.. autofunction:: django_downloadview.test.assert_download_response
Examples, related to :doc:`StorageDownloadView demo </views/storage>`:
.. 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 </views/storage>`:
.. literalinclude:: /../demo/demoproject/storage/tests.py
:language: python
:lines: 1-2, 8-12, 59-
See :py:mod:`django_downloadview.test` for details.

View file

@ -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 <django_downloadview.views.BaseDownloadView.get>`: it triggers
:py:meth:`DownloadMixin's render_to_response
<django_downloadview.views.DownloadMixin.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

Some files were not shown because too many files have changed in this diff Show more