Refreshed development environment. Added tests around Python 3.4 and Django 1.8. Fixed content proxied by HTTPFile. Closes #97, closes #98, closes #99.

This commit is contained in:
Benoît Bryon 2015-06-13 00:39:58 +02:00
commit 211fd5461b
32 changed files with 799 additions and 662 deletions

View file

@ -1,11 +1,20 @@
language: python
env:
- TOXENV=py27
- TOXENV=py33
- TOXENV=py27-django15
- TOXENV=py27-django16
- TOXENV=py27-django17
- TOXENV=py27-django18
- TOXENV=py33-django15
- TOXENV=py33-django16
- TOXENV=py33-django17
- TOXENV=py33-django18
- TOXENV=py34-django15
- TOXENV=py34-django16
- TOXENV=py34-django17
- TOXENV=py34-django18
- TOXENV=flake8
- TOXENV=sphinx
- TOXENV=readme
install:
- pip install tox
script:
- tox
- make test

View file

@ -8,7 +8,17 @@ future releases, check `milestones`_ and :doc:`/about/vision`.
1.7 (unreleased)
----------------
- Nothing changed yet.
- Bugfix #97 - ``HTTPFile`` proxies bytes as ``BytesIteratorIO`` (was undecoded
urllib3 file object). ``StringIteratorIO`` has been split into
``TextIteratorIO`` and ``BytesIteratorIO``. ``StringIteratorIO`` is
deprecated but kept for backward compatibility as an alias for
``TextIteratorIO``.
- Feature #99 - Tox runs project's tests with Python 2.7, 3.3 and 3.4, and with
Django 1.5 to 1.8.
- Refactoring #98 - Refreshed development environment: packaging, Tox and
Sphinx.
1.6 (2014-03-03)

67
INSTALL
View file

@ -7,28 +7,70 @@ Install
If you want to install a development environment, please see
:doc:`/contributing`.
System requirements:
* Python version 2.7
************
Requirements
************
Install the package with your favorite Python installer. As an example, with
pip:
.. code:: sh
pip install django-downloadview
`django-downloadview` has been tested with Python version 2.7, 3.3 and 3.4.
Installing `django-downloadview` will automatically trigger the installation of
the following requirements:
.. literalinclude:: /../setup.py
:language: python
:lines: 39
:start-after: BEGIN requirements
:end-before: END requirements
.. note::
Since version 1.1, django-downloadview requires Django>=1.5, which provides
:py:class:`~django.http.StreamingHttpResponse`.
************
As a library
************
In most cases, you will use `django-downloadview` as a dependency of another
project. In such a case, you should add `django-downloadview` in your main
project's requirements. Typically in :file:`setup.py`:
.. code:: python
from setuptools import setup
setup(
install_requires=[
'django-downloadview',
#...
]
# ...
)
Then when you install your main project with your favorite package manager
(like `pip`_), `django-downloadview` and its recursive dependencies will
automatically be installed.
**********
Standalone
**********
You can install `django-downloadview` with your favorite Python package
manager. As an example with `pip`_:
.. code:: sh
pip install django-downloadview
*****
Check
*****
Check `django-downloadview` has been installed:
.. code:: sh
python -c "import django_downloadview;print(django_downloadview.__version__)"
You should get installed `django-downloadview`'s version.
.. rubric:: Notes & references
@ -41,5 +83,6 @@ the following requirements:
.. target-notes::
.. _`pip`: https://pip.pypa.io/
.. _`django-downloadview's bugtracker`:
https://github.com/benoitbryon/django-downloadview/issues

View file

@ -9,39 +9,30 @@ PIP = pip
TOX = tox
.PHONY: all help develop clean distclean maintainer-clean test documentation sphinx readme demo runserver release
# Default target. Does nothing.
all:
@echo "Reference card for usual actions in development environment."
@echo "Nothing to do by default."
@echo "Try 'make help'."
#: help - Display callable targets.
.PHONY: help
help:
@echo "Reference card for usual actions in development environment."
@echo "Here are available targets:"
@egrep -o "^#: (.+)" [Mm]akefile | sed 's/#: /* /'
#: develop - Install minimal development utilities such as tox.
#: develop - Install minimal development utilities.
.PHONY: develop
develop:
mkdir -p var
$(PIP) install tox
$(PIP) install -e ./
$(PIP) install -e ./demo/
$(PIP) install -e .
#: clean - Basic cleanup, mostly temporary files.
.PHONY: clean
clean:
find . -name "*.pyc" -delete
find . -name '*.pyo' -delete
find . -name "__pycache__" -delete
find . -name ".noseids" -delete
#: distclean - Remove local builds, such as *.egg-info.
.PHONY: distclean
distclean: clean
rm -rf *.egg
rm -rf *.egg-info
@ -49,35 +40,42 @@ distclean: clean
#: maintainer-clean - Remove almost everything that can be re-generated.
.PHONY: maintainer-clean
maintainer-clean: distclean
rm -rf bin/
rm -rf lib/
rm -rf build/
rm -rf dist/
rm -rf .tox/
#: test - Run full test suite.
#: test - Run test suites.
.PHONY: test
test:
mkdir -p var
$(PIP) install -e .[test]
$(TOX)
#: sphinx - Build Sphinx documentation.
#: documentation - Build documentation (Sphinx, README, ...)
.PHONY: documentation
documentation: sphinx readme
#: sphinx - Build Sphinx documentation (docs).
.PHONY: sphinx
sphinx:
$(TOX) -e sphinx
#: readme - Build standalone documentation files (README, CONTRIBUTING...).
.PHONY: readme
readme:
$(TOX) -e readme
#: documentation - Build full documentation.
documentation: sphinx readme
demo: develop
demo syncdb --noinput
#: demo - Setup demo project.
.PHONY: demo
demo:
demo migrate --noinput
# Install fixtures.
mkdir -p var/media
cp -r demo/demoproject/fixtures var/media/object
@ -86,10 +84,13 @@ demo: develop
demo loaddata demo.json
#: runserver - Run demo server.
.PHONY: runserver
runserver: demo
demo runserver
#: release - Tag and push to PyPI.
.PHONY: release
release:
$(TOX) -e release

View file

@ -2,24 +2,24 @@
django-downloadview
###################
``django-downloadview`` makes it easy to serve files with Django:
`django-downloadview` makes it easy to serve files with `Django`_:
* you manage files with Django (permissions, search, generation, ...);
* you manage files with Django (permissions, filters, 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 stream the files with very little code;
* ``django-downloadview`` helps you improve performances with reverse proxies,
via mechanisms such as Nginx's X-Accel.
* `django-downloadview` helps you improve performances with reverse proxies,
via mechanisms such as Nginx's X-Accel or Apache's X-Sendfile.
*******
Example
*******
Let's serve a file stored in a FileField of some model:
Let's serve a file stored in a file field of some model:
.. code:: python
@ -45,3 +45,6 @@ Resources
* Bugtracker: https://github.com/benoitbryon/django-downloadview/issues
* Continuous integration: https://travis-ci.org/benoitbryon/django-downloadview
* Roadmap: https://github.com/benoitbryon/django-downloadview/milestones
.. _`Django`: https://djangoproject.com

View file

@ -49,7 +49,7 @@ Execute:
make runserver
It installs and runs the demo server on localhost, port 8000. So have a look
at http://localhost:8000/
at ``http://localhost:8000/``.
.. note::

View file

@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
"""Django settings for Django-DownloadView demo project."""
from os.path import abspath, dirname, join
"""Django settings for django-downloadview demo project."""
import os
# Configure some relative directories.
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')
demoproject_dir = os.path.dirname(os.path.abspath(__file__))
demo_dir = os.path.dirname(demoproject_dir)
root_dir = os.path.dirname(demo_dir)
data_dir = os.path.join(root_dir, 'var')
cfg_dir = os.path.join(root_dir, 'etc')
# Mandatory settings.
@ -20,7 +20,7 @@ WSGI_APPLICATION = 'demoproject.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': join(data_dir, 'db.sqlite'),
'NAME': os.path.join(data_dir, 'db.sqlite'),
}
}
@ -29,21 +29,14 @@ DATABASES = {
SECRET_KEY = "This is a secret made public on project's repository."
# Media and static files.
MEDIA_ROOT = join(data_dir, 'media')
MEDIA_ROOT = os.path.join(data_dir, 'media')
MEDIA_URL = '/media/'
STATIC_ROOT = join(data_dir, 'static')
STATIC_ROOT = os.path.join(data_dir, 'static')
STATIC_URL = '/static/'
# Applications.
INSTALLED_APPS = (
# Standard Django applications.
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
# The actual django-downloadview demo.
'demoproject',
'demoproject.object', # Demo around ObjectDownloadView
@ -54,13 +47,19 @@ INSTALLED_APPS = (
'demoproject.nginx', # Sample optimizations for Nginx X-Accel.
'demoproject.apache', # Sample optimizations for Apache X-Sendfile.
'demoproject.lighttpd', # Sample optimizations for Lighttpd X-Sendfile.
# For test purposes. The demo project is part of django-downloadview
# test suite.
# Standard Django applications.
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
# Stuff that must be at the end.
'django_nose',
)
# Middlewares.
# BEGIN middlewares
MIDDLEWARE_CLASSES = [
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
@ -69,19 +68,27 @@ MIDDLEWARE_CLASSES = [
'django.contrib.messages.middleware.MessageMiddleware',
'django_downloadview.SmartDownloadMiddleware'
]
# END middlewares
# Specific configuration for django_downloadview.SmartDownloadMiddleware.
# BEGIN backend
DOWNLOADVIEW_BACKEND = 'django_downloadview.nginx.XAccelRedirectMiddleware'
# END backend
"""Could also be:
DOWNLOADVIEW_BACKEND = 'django_downloadview.apache.XSendfileMiddleware'
DOWNLOADVIEW_BACKEND = 'django_downloadview.lighttpd.XSendfileMiddleware'
"""
# BEGIN rules
DOWNLOADVIEW_RULES = [
{
'source_url': '/media/nginx/',
'destination_url': '/nginx-optimized-by-middleware/',
},
]
# END rules
DOWNLOADVIEW_RULES += [
{
'source_url': '/media/apache/',
'destination_dir': '/apache-optimized-by-middleware/',
@ -107,7 +114,10 @@ DOWNLOADVIEW_RULES = [
DEBUG = True
TEMPLATE_DEBUG = DEBUG
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
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__)]
NOSE_ARGS = [
'--verbosity=2',
'--no-path-adjustment',
'--nocapture',
'--all-modules',
'--with-coverage',
]

View file

@ -4,7 +4,7 @@ from django.core.files.base import ContentFile
from django_downloadview import VirtualDownloadView
from django_downloadview import VirtualFile
from django_downloadview import StringIteratorIO
from django_downloadview import TextIteratorIO
class TextDownloadView(VirtualDownloadView):
@ -15,7 +15,7 @@ class TextDownloadView(VirtualDownloadView):
class StringIODownloadView(VirtualDownloadView):
def get_file(self):
"""Return wrapper on ``StringIO`` object."""
"""Return wrapper on ``six.StringIO`` object."""
file_obj = StringIO(u"Hello world!\n")
return VirtualFile(file_obj, name='hello-world.txt')
@ -29,5 +29,5 @@ def generate_hello():
class GeneratedDownloadView(VirtualDownloadView):
def get_file(self):
"""Return wrapper on ``StringIteratorIO`` object."""
file_obj = StringIteratorIO(generate_hello())
file_obj = TextIteratorIO(generate_hello())
return VirtualFile(file_obj, name='hello-world.txt')

View file

@ -22,7 +22,9 @@ CLASSIFIERS = ['Development Status :: 4 - Beta',
'Framework :: Django']
KEYWORDS = []
PACKAGES = ['demoproject']
REQUIREMENTS = ['django-downloadview', 'django-nose']
REQUIREMENTS = [
'django-downloadview',
'django-nose']
ENTRY_POINTS = {
'console_scripts': ['demo = demoproject.manage:main']
}

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
"""Declaration of API shortcuts."""
from django_downloadview.io import StringIteratorIO # NoQA
from django_downloadview.io import (BytesIteratorIO, # NoQA
TextIteratorIO)
from django_downloadview.files import (StorageFile, # NoQA
VirtualFile,
HTTPFile)
@ -20,3 +21,7 @@ from django_downloadview.shortcuts import sendfile # NoQA
from django_downloadview.test import (assert_download_response, # NoQA
setup_view,
temporary_media_root)
# Backward compatibility.
StringIteratorIO = TextIteratorIO

View file

@ -9,6 +9,8 @@ from django.utils.encoding import force_bytes
import requests
from django_downloadview.io import BytesIteratorIO
class StorageFile(File):
"""A file in a Django storage.
@ -239,7 +241,12 @@ class HTTPFile(File):
@property
def file(self):
return self.request.raw
try:
return self._file
except AttributeError:
content = self.request.iter_content(decode_unicode=False)
self._file = BytesIteratorIO(content)
return self._file
@property
def size(self):

View file

@ -3,9 +3,11 @@
from __future__ import absolute_import
import io
from django.utils.encoding import force_text, force_bytes
class StringIteratorIO(io.TextIOBase):
"""A dynamically generated StringIO-like object.
class TextIteratorIO(io.TextIOBase):
"""A dynamically generated TextIO-like object.
Original code by Matt Joiner <anacrolix@gmail.com> from:
@ -14,8 +16,11 @@ class StringIteratorIO(io.TextIOBase):
"""
def __init__(self, iterator):
#: Iterator/generator for content.
self._iter = iterator
self._left = ''
#: Internal buffer.
self._left = u''
def readable(self):
return True
@ -26,11 +31,15 @@ class StringIteratorIO(io.TextIOBase):
self._left = next(self._iter)
except StopIteration:
break
else:
# Make sure we handle text.
self._left = force_text(self._left)
ret = self._left[:n]
self._left = self._left[len(ret):]
return ret
def read(self, n=None):
"""Return content up to ``n`` length."""
l = []
if n is None or n < 0:
while True:
@ -45,21 +54,89 @@ class StringIteratorIO(io.TextIOBase):
break
n -= len(m)
l.append(m)
return ''.join(l)
return u''.join(l)
def readline(self):
l = []
while True:
i = self._left.find('\n')
i = self._left.find(u'\n')
if i == -1:
l.append(self._left)
try:
self._left = next(self._iter)
except StopIteration:
self._left = ''
self._left = u''
break
else:
l.append(self._left[:i + 1])
self._left = self._left[i + 1:]
break
return ''.join(l)
return u''.join(l)
class BytesIteratorIO(io.BytesIO):
"""A dynamically generated BytesIO-like object.
Original code by Matt Joiner <anacrolix@gmail.com> from:
* http://stackoverflow.com/questions/12593576/
* https://gist.github.com/anacrolix/3788413
"""
def __init__(self, iterator):
#: Iterator/generator for content.
self._iter = iterator
#: Internal buffer.
self._left = b''
def readable(self):
return True
def _read1(self, n=None):
while not self._left:
try:
self._left = next(self._iter)
except StopIteration:
break
else:
# Make sure we handle text.
self._left = force_bytes(self._left)
ret = self._left[:n]
self._left = self._left[len(ret):]
return ret
def read(self, n=None):
"""Return content up to ``n`` length."""
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 b''.join(l)
def readline(self):
l = []
while True:
i = self._left.find(b'\n')
if i == -1:
l.append(self._left)
try:
self._left = next(self._iter)
except StopIteration:
self._left = b''
break
else:
l.append(self._left[:i + 1])
self._left = self._left[i + 1:]
break
return b''.join(l)

View file

@ -5,6 +5,7 @@ import tempfile
from django.conf import settings
from django.test.utils import override_settings
from django.utils.encoding import force_bytes
from django_downloadview.middlewares import is_download_response
from django_downloadview.response import (encode_basename_ascii,
@ -139,9 +140,9 @@ class DownloadResponseValidator(object):
test_case.assertTrue(response['Content-Type'].startswith(value))
def assert_content(self, test_case, response, value):
test_case.assertEqual(
''.join([s.decode('utf-8') for s in response.streaming_content]),
value)
"""Assert value equals response's content (byte comparison)."""
parts = [force_bytes(s) for s in response.streaming_content]
test_case.assertEqual(b''.join(parts), force_bytes(value))
def assert_attachment(self, test_case, response, value):
if value:

View file

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

View file

@ -1,302 +0,0 @@
# -*- coding: utf-8 -*-
"""Unit tests around views."""
import os
import unittest
try:
from unittest import mock
except ImportError:
import mock
from django.core.files import File
from django.http import Http404
from django.http.response import HttpResponseNotModified
import django.test
from django_downloadview import exceptions
from django_downloadview.test import setup_view
from django_downloadview.response import DownloadResponse
from django_downloadview import views
from django_downloadview.shortcuts import sendfile
class DownloadMixinTestCase(unittest.TestCase):
"""Tests around :class:`django_downloadviews.views.base.DownloadMixin`."""
def test_get_file(self):
"""DownloadMixin.get_file() raise NotImplementedError.
Subclasses must implement it!
"""
mixin = views.DownloadMixin()
with self.assertRaises(NotImplementedError):
mixin.get_file()
def test_get_basename(self):
"""DownloadMixin.get_basename() returns basename attribute."""
mixin = views.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 = views.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 = views.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 = views.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 = views.DownloadMixin()
response = mixin.not_modified_response()
self.assertTrue(isinstance(response, HttpResponseNotModified))
def test_download_response(self):
"DownloadMixin.download_response() returns download response instance."
mixin = views.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,
'file_mimetype': None,
'file_encoding': None}
response = mixin.download_response(**response_kwargs)
self.assertIs(response, mock.sentinel.response)
response_factory.assert_called_once_with(**response_kwargs) # Not args
def test_render_to_response_not_modified(self):
"""DownloadMixin.render_to_response() respects HTTP_IF_MODIFIED_SINCE
header (calls ``not_modified_response()``)."""
# Setup.
mixin = views.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 = views.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()
def test_render_to_response_file_not_found(self):
"DownloadMixin.render_to_response() calls file_not_found_response()."
# Setup.
mixin = views.DownloadMixin()
mixin.request = django.test.RequestFactory().get('/dummy-url')
mixin.get_file = mock.Mock(side_effect=exceptions.FileNotFound)
mixin.file_not_found_response = mock.Mock()
# Run.
mixin.render_to_response()
# Check.
mixin.file_not_found_response.assert_called_once_with()
def test_file_not_found_response(self):
"""DownloadMixin.file_not_found_response() raises Http404."""
mixin = views.DownloadMixin()
with self.assertRaises(Http404):
mixin.file_not_found_response()
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(views.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()
class PathDownloadViewTestCase(unittest.TestCase):
"Tests for :class:`django_downloadviews.views.path.PathDownloadView`."
def test_get_file_ok(self):
"PathDownloadView.get_file() returns ``File`` instance."
view = setup_view(views.PathDownloadView(path=__file__),
'fake request')
file_wrapper = view.get_file()
self.assertTrue(isinstance(file_wrapper, File))
def test_get_file_does_not_exist(self):
"""PathDownloadView.get_file() raises FileNotFound if field does not
exist.
"""
view = setup_view(views.PathDownloadView(path='i-do-no-exist'),
'fake request')
with self.assertRaises(exceptions.FileNotFound):
view.get_file()
def test_get_file_is_directory(self):
"""PathDownloadView.get_file() raises FileNotFound if file is a
directory."""
view = setup_view(
views.PathDownloadView(path=os.path.dirname(__file__)),
'fake request')
with self.assertRaises(exceptions.FileNotFound):
view.get_file()
class ObjectDownloadViewTestCase(unittest.TestCase):
"Tests for :class:`django_downloadviews.views.object.ObjectDownloadView`."
def test_get_file_ok(self):
"ObjectDownloadView.get_file() returns ``file`` field by default."
view = setup_view(views.ObjectDownloadView(), 'fake request')
view.object = mock.Mock(spec=['file'])
view.get_file()
def test_get_file_wrong_field(self):
"""ObjectDownloadView.get_file() raises AttributeError if field does
not exist.
``AttributeError`` is expected because this is a configuration error,
i.e. it is related to Python code.
"""
view = setup_view(views.ObjectDownloadView(file_field='other_field'),
'fake request')
view.object = mock.Mock(spec=['file'])
with self.assertRaises(AttributeError):
view.get_file()
def test_get_file_empty_field(self):
"""ObjectDownloadView.get_file() raises FileNotFound if field does not
exist."""
view = setup_view(views.ObjectDownloadView(file_field='other_field'),
'fake request')
view.object = mock.Mock()
view.object.other_field = None
with self.assertRaises(exceptions.FileNotFound):
view.get_file()
class SendfileTestCase(django.test.TestCase):
"""Tests around :func:`django_downloadview.sendfile.sendfile`."""
def test_defaults(self):
"""sendfile() takes at least request and filename."""
request = django.test.RequestFactory().get('/fake-url')
filename = __file__
response = sendfile(request, filename)
self.assertTrue(isinstance(response, DownloadResponse))
self.assertFalse(response.attachment)
def test_custom(self):
"""sendfile() accepts various arguments for response tuning."""
request = django.test.RequestFactory().get('/fake-url')
filename = __file__
response = sendfile(request,
filename,
attachment=True,
attachment_filename='toto.txt',
mimetype='test/octet-stream',
encoding='gzip')
self.assertTrue(isinstance(response, DownloadResponse))
self.assertTrue(response.attachment)
self.assertEqual(response.basename, 'toto.txt')
self.assertEqual(response['Content-Type'],
'test/octet-stream; charset=utf-8')
self.assertEqual(response.get_encoding(), 'gzip')
def test_404(self):
"""sendfile() raises Http404 if file does not exists."""
request = django.test.RequestFactory().get('/fake-url')
filename = 'i-do-no-exist'
with self.assertRaises(Http404):
sendfile(request, filename)

View file

@ -19,8 +19,8 @@ optimizations.
* :doc:`/about/alternatives`
* `roadmap
<https://github.com/benoitbryon/django-downloadview/issues/milestones>`_
<https://github.com/benoitbryon/django-downloadview/milestones>`_
.. target-notes::
.. _`Django`: https://django-project.com
.. _`Django`: https://djangoproject.com

View file

@ -1,16 +1,7 @@
# -*- coding: utf-8 -*-
#
# django-downloadview documentation build configuration file, created by
# sphinx-quickstart on Mon Aug 27 11:37:23 2012.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
"""django-downloadview documentation build configuration file."""
import os
import re
# Minimal Django settings. Required to use sphinx.ext.autodoc, because
@ -23,28 +14,16 @@ settings.configure(
)
doc_dir = os.path.dirname(os.path.abspath(__file__))
project_dir = os.path.dirname(doc_dir)
version_filename = os.path.join(project_dir, 'VERSION')
# -- General configuration ----------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# 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',
'sphinx.ext.intersphinx']
# Extensions.
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']
@ -53,102 +32,55 @@ templates_path = ['_templates']
source_suffix = '.txt'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
source_encoding = 'utf-8'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'django-downloadview'
copyright = u'2012, Benoît Bryon'
project_slug = re.sub(r'([\w_.-]+)', u'-', project)
copyright = u'2012-2015, Benoît Bryon'
author = u'Benoît Bryon'
author_slug = re.sub(r'([\w_.-]+)', u'-', author)
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = open(version_filename).read().strip()
configuration_dir = os.path.dirname(__file__)
documentation_dir = configuration_dir
version_file = os.path.normpath(os.path.join(
documentation_dir,
'../VERSION'))
# The full version, including alpha/beta/rc tags.
release = version
release = open(version_file).read().strip()
# The short X.Y version.
version = '.'.join(release.split('.')[0:1])
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
language = 'en'
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------------
# -- Options for HTML output --------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
html_theme = 'alabaster'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
html_sidebars = {
'**': ['globaltoc.html',
@ -157,119 +89,60 @@ html_sidebars = {
'searchbox.html'],
}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'django-downloadviewdoc'
htmlhelp_basename = u'{project}doc'.format(project=project_slug)
# -- 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/'),
'django': ('http://docs.djangoproject.com/en/1.8/',
'http://docs.djangoproject.com/en/1.8/_objects/'),
'requests': ('http://docs.python-requests.org/en/latest/', None),
}
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# -- Options for LaTeX output -------------------------------------------------
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
latex_elements = {}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
# (source start file, target name, title, author, documentclass
# [howto/manual]).
latex_documents = [
('index', 'django-downloadview.tex', u'django-downloadview Documentation',
u'Benoît Bryon', 'manual'),
('index',
u'{project}.tex'.format(project=project_slug),
u'{project} Documentation'.format(project=project),
author,
'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# -- Options for manual page output -------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'django-downloadview', u'django-downloadview Documentation',
[u'Benoît Bryon'], 1)
('index',
project,
u'{project} Documentation'.format(project=project),
[author],
1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
# -- Options for Texinfo output -----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'django-downloadview', u'django-downloadview Documentation',
u'Benoît Bryon', 'django-downloadview', 'One line description of project.',
'Miscellaneous'),
('index',
project_slug,
u'{project} Documentation'.format(project=project),
author,
project,
'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'

View file

@ -48,6 +48,27 @@ django-downloadview builtins
This is a convenient wrapper to use in :doc:`/views/virtual` subclasses.
**********************
Low-level IO utilities
**********************
`django-downloadview` provides two classes to implement file-like objects
whose content is dynamically generated:
* :class:`~django_downloadview.io.TextIteratorIO` for generated text;
* :class:`~django_downloadview.io.BytesIteratorIO` for generated bytes.
These classes may be handy to serve dynamically generated files. See
:doc:`/views/virtual` for details.
.. tip::
**Text or bytes?** (formerly "unicode or str?") As `django-downloadview`
is meant to serve files, as opposed to read or parse files, what matters
is file contents is preserved. `django-downloadview` tends to handle files
in binary mode and as bytes.
*************
API reference
*************
@ -81,6 +102,26 @@ VirtualFile
:member-order: bysource
BytesIteratorIO
===============
.. autoclass:: django_downloadview.io.BytesIteratorIO
:members:
:undoc-members:
:show-inheritance:
:member-order: bysource
TextIteratorIO
==============
.. autoclass:: django_downloadview.io.TextIteratorIO
:members:
:undoc-members:
:show-inheritance:
:member-order: bysource
.. rubric:: Notes & references
.. target-notes::

View file

@ -4,14 +4,14 @@ Overview, concepts
Given:
* you manage files with Django (permissions, search, generation, ...)
* you manage files with Django (permissions, filters, 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...
Here is an overview of `django-downloadview`'s answer...
************************************
@ -23,9 +23,8 @@ 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...
* :doc:`/views/http`: file at URL (the resource is proxied);
* :doc:`/views/virtual`: bytes, text, file-like objects, generated files...
*************************************************
@ -67,14 +66,15 @@ 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
Before WSGI application use file wrapper and actually use 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.
`nginx`_ via `X-Accel`_ internal redirects. This way, Django doesn't load file
content in memory.
`django_downloadview` provides middlewares that convert
:class:`~django_downloadview.response.DownloadResponse` into

View file

@ -2,23 +2,22 @@
Configure
#########
Here is the list of settings used by `django-downloadview`.
Here is the list of Django settings for `django-downloadview`.
**************
INSTALLED_APPS
**************
There is no need to register this application in your Django's
``INSTALLED_APPS`` setting.
There is no need to register this application in ``INSTALLED_APPS``.
******************
MIDDLEWARE_CLASSES
******************
If you plan to setup reverse-proxy optimizations, add
``django_downloadview.SmartDownloadMiddleware`` to ``MIDDLEWARE_CLASSES``.
If you plan to setup :doc:`reverse-proxy optimizations </optimizations/index>`,
add ``django_downloadview.SmartDownloadMiddleware`` to ``MIDDLEWARE_CLASSES``.
It is a response middleware. Move it after middlewares that compute the
response content such as gzip middleware.
@ -26,7 +25,8 @@ Example:
.. literalinclude:: /../demo/demoproject/settings.py
:language: python
:lines: 64-71
:start-after: BEGIN middlewares
:end-before: END middlewares
********************
@ -43,7 +43,8 @@ Example:
.. literalinclude:: /../demo/demoproject/settings.py
:language: python
:lines: 75
:start-after: BEGIN backend
:end-before: END backend
See :doc:`/optimizations/index` for a list of available backends (middlewares).
@ -69,7 +70,8 @@ Here is an example containing one rule using keyword arguments:
.. literalinclude:: /../demo/demoproject/settings.py
:language: python
:lines: 80, 81-84, 103
:start-after: BEGIN rules
:end-before: END rules
See :doc:`/optimizations/index` for details about builtin backends
(middlewares) and their options.

View file

@ -14,8 +14,8 @@ it returns a suitable file wrapper...
.. note::
Current implementation does not support reverse-proxy optimizations,
because there is no place reverse-proxy can load files from after Django
exited.
because content is actually generated within Django, not stored in some
third-party place.
************
@ -68,7 +68,7 @@ Let's consider you have a generator function (``yield``) or an iterator object
Stream generated content using :class:`VirtualDownloadView`,
:class:`~django_downloadview.files.VirtualFile` and
:class:`~django_downloadview.file.StringIteratorIO`:
:class:`~django_downloadview.io.BytesIteratorIO`:
.. literalinclude:: /../demo/demoproject/virtual/views.py
:language: python

View file

@ -1,9 +0,0 @@
[nosetests]
verbosity = 2
nocapture = True
with-doctest = True
rednose = True
no-path-adjustment = True
all-modules = True
cover-inclusive = True
cover-tests = True

View file

@ -1,4 +0,0 @@
[nosetests]
with-coverage = True
cover-package = demoproject
tests = demoproject

View file

@ -1,4 +0,0 @@
[nosetests]
with-coverage = True
cover-package = django_downloadview
tests = django_downloadview,tests

View file

@ -1,9 +1,24 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Python packaging."""
import os
import sys
from setuptools import setup
from setuptools.command.test import test as TestCommand
class Tox(TestCommand):
"""Test command that runs tox."""
def finalize_options(self):
TestCommand.finalize_options(self)
self.test_args = []
self.test_suite = True
def run_tests(self):
import tox # import here, cause outside the eggs aren't loaded.
errno = tox.cmdline(self.test_args)
sys.exit(errno)
#: Absolute path to directory containing setup.py file.
@ -18,8 +33,9 @@ README = open(os.path.join(here, 'README.rst')).read()
VERSION = open(os.path.join(here, 'VERSION')).read().strip()
AUTHOR = u'Benoît Bryon'
EMAIL = 'benoit@marmelune.net'
LICENSE = 'BSD'
URL = 'https://{name}.readthedocs.org/'.format(name=NAME)
CLASSIFIERS = ['Development Status :: 4 - Beta',
CLASSIFIERS = ['Development Status :: 5 - Production/Stable',
'License :: OSI Approved :: BSD License',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.3',
@ -36,25 +52,42 @@ KEYWORDS = ['file',
'mod_xsendfile',
'offload']
PACKAGES = [NAME.replace('-', '_')]
REQUIREMENTS = ['setuptools', 'Django>=1.5', 'requests', 'six']
if IS_PYTHON2:
REQUIREMENTS.append('mock')
REQUIREMENTS = [
# BEGIN requirements
'Django>=1.5',
'requests',
'setuptools',
'six',
# END requirements
]
ENTRY_POINTS = {}
SETUP_REQUIREMENTS = ['setuptools']
TEST_REQUIREMENTS = ['tox']
CMDCLASS = {'test': Tox}
EXTRA_REQUIREMENTS = {
'test': TEST_REQUIREMENTS,
}
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)
setup(
name=NAME,
version=VERSION,
description=DESCRIPTION,
long_description=README,
classifiers=CLASSIFIERS,
keywords=' '.join(KEYWORDS),
author=AUTHOR,
author_email=EMAIL,
url=URL,
license=LICENSE,
packages=PACKAGES,
include_package_data=True,
zip_safe=False,
install_requires=REQUIREMENTS,
entry_points=ENTRY_POINTS,
tests_require=TEST_REQUIREMENTS,
cmdclass=CMDCLASS,
setup_requires=SETUP_REQUIREMENTS,
extras_require=EXTRA_REQUIREMENTS,
)

0
tests/__init__.py Normal file
View file

53
tests/io.py Normal file
View file

@ -0,0 +1,53 @@
# coding=utf-8
"""Tests around :mod:`django_downloadview.io`."""
import unittest
from django_downloadview import TextIteratorIO, BytesIteratorIO
HELLO_TEXT = u'Hello world!\né\n'
HELLO_BYTES = b'Hello world!\n\xc3\xa9\n'
def generate_hello_text():
"""Generate u'Hello world!\n'."""
yield u'Hello '
yield u'world!'
yield u'\n'
yield u'é'
yield u'\n'
def generate_hello_bytes():
"""Generate b'Hello world!\n'."""
yield b'Hello '
yield b'world!'
yield b'\n'
yield b'\xc3\xa9'
yield b'\n'
class TextIteratorIOTestCase(unittest.TestCase):
"""Tests around :class:`~django_downloadview.io.TextIteratorIO`."""
def test_read_text(self):
"""TextIteratorIO obviously accepts text generator."""
file_obj = TextIteratorIO(generate_hello_text())
self.assertEqual(file_obj.read(), HELLO_TEXT)
def test_read_bytes(self):
"""TextIteratorIO converts bytes as text."""
file_obj = TextIteratorIO(generate_hello_bytes())
self.assertEqual(file_obj.read(), HELLO_TEXT)
class BytesIteratorIOTestCase(unittest.TestCase):
"""Tests around :class:`~django_downloadview.io.BytesIteratorIO`."""
def test_read_bytes(self):
"""BytesIteratorIO obviously accepts bytes generator."""
file_obj = BytesIteratorIO(generate_hello_bytes())
self.assertEqual(file_obj.read(), HELLO_BYTES)
def test_read_text(self):
"""BytesIteratorIO converts text as bytes."""
file_obj = BytesIteratorIO(generate_hello_text())
self.assertEqual(file_obj.read(), HELLO_BYTES)

42
tests/sendfile.py Normal file
View file

@ -0,0 +1,42 @@
# coding=utf-8
"""Tests around :py:mod:`django_downloadview.sendfile`."""
from django.http import Http404
import django.test
from django_downloadview.response import DownloadResponse
from django_downloadview.shortcuts import sendfile
class SendfileTestCase(django.test.TestCase):
"""Tests around :func:`django_downloadview.sendfile.sendfile`."""
def test_defaults(self):
"""sendfile() takes at least request and filename."""
request = django.test.RequestFactory().get('/fake-url')
filename = __file__
response = sendfile(request, filename)
self.assertTrue(isinstance(response, DownloadResponse))
self.assertFalse(response.attachment)
def test_custom(self):
"""sendfile() accepts various arguments for response tuning."""
request = django.test.RequestFactory().get('/fake-url')
filename = __file__
response = sendfile(request,
filename,
attachment=True,
attachment_filename='toto.txt',
mimetype='test/octet-stream',
encoding='gzip')
self.assertTrue(isinstance(response, DownloadResponse))
self.assertTrue(response.attachment)
self.assertEqual(response.basename, 'toto.txt')
self.assertEqual(response['Content-Type'],
'test/octet-stream; charset=utf-8')
self.assertEqual(response.get_encoding(), 'gzip')
def test_404(self):
"""sendfile() raises Http404 if file does not exists."""
request = django.test.RequestFactory().get('/fake-url')
filename = 'i-do-no-exist'
with self.assertRaises(Http404):
sendfile(request, filename)

View file

@ -1,29 +1,41 @@
# coding=utf-8
"""Tests around :py:mod:`django_downloadview.views`."""
"""Tests around :mod:`django_downloadview.views`."""
import os
import unittest
try:
from unittest import mock
except ImportError:
import mock
from django.core.files import File
from django.http import Http404
from django.http.response import HttpResponseNotModified
import django.test
from django_downloadview import exceptions
from django_downloadview.test import setup_view
from django_downloadview import views
def setup_view(view, request, *args, **kwargs):
"""Mimic as_view() returned callable, but returns view instance.
``*args`` and ``**kwargs`` are the same you would pass to ``reverse()``
"""
view.request = request
view.args = args
view.kwargs = kwargs
return view
class DownloadMixinTestCase(unittest.TestCase):
"""Test suite around :py:class:`django_downloadview.views.DownloadMixin`.
"""
"""Test suite around :class:`django_downloadview.views.DownloadMixin`."""
def test_get_file(self):
"""DownloadMixin.get_file() raise NotImplementedError.
Subclasses must implement it!
"""
mixin = views.DownloadMixin()
with self.assertRaises(NotImplementedError):
mixin.get_file()
def test_get_basename(self):
"""DownloadMixin.get_basename() returns basename attribute."""
mixin = views.DownloadMixin()
self.assertEqual(mixin.get_basename(), None)
mixin.basename = 'fake'
self.assertEqual(mixin.get_basename(), 'fake')
def test_was_modified_since_specific(self):
"""DownloadMixin.was_modified_since() delegates to file wrapper."""
file_wrapper = mock.Mock()
@ -49,6 +61,234 @@ class DownloadMixinTestCase(unittest.TestCase):
since = mock.sentinel.since
self.assertTrue(mixin.was_modified_since(file_wrapper, since))
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 = views.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 = views.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 = views.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 = views.DownloadMixin()
response = mixin.not_modified_response()
self.assertTrue(isinstance(response, HttpResponseNotModified))
def test_download_response(self):
"DownloadMixin.download_response() returns download response instance."
mixin = views.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,
'file_mimetype': None,
'file_encoding': None}
response = mixin.download_response(**response_kwargs)
self.assertIs(response, mock.sentinel.response)
response_factory.assert_called_once_with(**response_kwargs) # Not args
def test_render_to_response_not_modified(self):
"""DownloadMixin.render_to_response() respects HTTP_IF_MODIFIED_SINCE
header (calls ``not_modified_response()``)."""
# Setup.
mixin = views.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 = views.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()
def test_render_to_response_file_not_found(self):
"DownloadMixin.render_to_response() calls file_not_found_response()."
# Setup.
mixin = views.DownloadMixin()
mixin.request = django.test.RequestFactory().get('/dummy-url')
mixin.get_file = mock.Mock(side_effect=exceptions.FileNotFound)
mixin.file_not_found_response = mock.Mock()
# Run.
mixin.render_to_response()
# Check.
mixin.file_not_found_response.assert_called_once_with()
def test_file_not_found_response(self):
"""DownloadMixin.file_not_found_response() raises Http404."""
mixin = views.DownloadMixin()
with self.assertRaises(Http404):
mixin.file_not_found_response()
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(views.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()
class PathDownloadViewTestCase(unittest.TestCase):
"Tests for :class:`django_downloadviews.views.path.PathDownloadView`."
def test_get_file_ok(self):
"PathDownloadView.get_file() returns ``File`` instance."
view = setup_view(views.PathDownloadView(path=__file__),
'fake request')
file_wrapper = view.get_file()
self.assertTrue(isinstance(file_wrapper, File))
def test_get_file_does_not_exist(self):
"""PathDownloadView.get_file() raises FileNotFound if field does not
exist.
"""
view = setup_view(views.PathDownloadView(path='i-do-no-exist'),
'fake request')
with self.assertRaises(exceptions.FileNotFound):
view.get_file()
def test_get_file_is_directory(self):
"""PathDownloadView.get_file() raises FileNotFound if file is a
directory."""
view = setup_view(
views.PathDownloadView(path=os.path.dirname(__file__)),
'fake request')
with self.assertRaises(exceptions.FileNotFound):
view.get_file()
class ObjectDownloadViewTestCase(unittest.TestCase):
"Tests for :class:`django_downloadviews.views.object.ObjectDownloadView`."
def test_get_file_ok(self):
"ObjectDownloadView.get_file() returns ``file`` field by default."
view = setup_view(views.ObjectDownloadView(), 'fake request')
view.object = mock.Mock(spec=['file'])
view.get_file()
def test_get_file_wrong_field(self):
"""ObjectDownloadView.get_file() raises AttributeError if field does
not exist.
``AttributeError`` is expected because this is a configuration error,
i.e. it is related to Python code.
"""
view = setup_view(views.ObjectDownloadView(file_field='other_field'),
'fake request')
view.object = mock.Mock(spec=['file'])
with self.assertRaises(AttributeError):
view.get_file()
def test_get_file_empty_field(self):
"""ObjectDownloadView.get_file() raises FileNotFound if field does not
exist."""
view = setup_view(views.ObjectDownloadView(file_field='other_field'),
'fake request')
view.object = mock.Mock()
view.object.other_field = None
with self.assertRaises(exceptions.FileNotFound):
view.get_file()
class VirtualDownloadViewTestCase(unittest.TestCase):
"""Test suite around

38
tox.ini
View file

@ -1,40 +1,45 @@
[tox]
envlist = py27,py33,flake8,sphinx,readme
envlist = py{27,33,34}-django{15,16,17,18}, flake8, sphinx, readme
[testenv]
basepython =
py27: python2.7
py33: python3.3
py34: python3.4
deps =
nose
rednose
coverage
django15: Django>=1.5,<1.6
django16: Django>=1.6,<1.7
django17: Django>=1.7,<1.8
django18: Django>=1.8,<1.9
nose
py27: mock
commands =
pip install ./
pip install -e demo/
demo test --nose-verbosity=2 -c etc/nose/base.cfg -c etc/nose/django_downloadview.cfg django_downloadview
demo test --nose-verbosity=2 demoproject
rm .coverage
pip install -e .
pip install -e demo
demo test --cover-package=django_downloadview --cover-package=demoproject {posargs: tests demoproject}
coverage erase
pip freeze
whitelist_externals =
rm
[testenv:flake8]
basepython = python2.7
deps =
flake8
commands =
flake8 django_downloadview/
flake8 demo/demoproject/
flake8 demo django_downloadview tests
[testenv:sphinx]
basepython = python2.7
deps =
nose
rednose
Sphinx
commands =
pip install ./
make --directory=docs SPHINXBUILD="sphinx-build -W" clean html doctest
pip install -e .
make --directory=docs SPHINXOPTS='-W' clean {posargs:html doctest linkcheck}
whitelist_externals =
make
[testenv:readme]
basepython = python2.7
deps =
docutils
pygments
@ -47,6 +52,7 @@ whitelist_externals =
[testenv:release]
deps =
wheel
zest.releaser
commands =
fullrelease