diff --git a/.travis.yml b/.travis.yml index c3c57cf..b207d50 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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 diff --git a/CHANGELOG b/CHANGELOG index 578729a..daf9bae 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/INSTALL b/INSTALL index 03347af..cf9865d 100644 --- a/INSTALL +++ b/INSTALL @@ -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 diff --git a/Makefile b/Makefile index aebdf77..1a9b8bd 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.rst b/README.rst index af4ec8c..c5c7909 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/demo/README.rst b/demo/README.rst index dc9a78d..4a2baa5 100644 --- a/demo/README.rst +++ b/demo/README.rst @@ -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:: diff --git a/demo/demoproject/settings.py b/demo/demoproject/settings.py index 29bafb9..a5d9d1a 100755 --- a/demo/demoproject/settings.py +++ b/demo/demoproject/settings.py @@ -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', +] diff --git a/demo/demoproject/virtual/views.py b/demo/demoproject/virtual/views.py index 805651f..3dc8ed2 100644 --- a/demo/demoproject/virtual/views.py +++ b/demo/demoproject/virtual/views.py @@ -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') diff --git a/demo/setup.py b/demo/setup.py index b9c5dcc..126e92e 100644 --- a/demo/setup.py +++ b/demo/setup.py @@ -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'] } diff --git a/django_downloadview/api.py b/django_downloadview/api.py index c768ad5..f0726b7 100644 --- a/django_downloadview/api.py +++ b/django_downloadview/api.py @@ -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 diff --git a/django_downloadview/files.py b/django_downloadview/files.py index e41534d..a83f31b 100644 --- a/django_downloadview/files.py +++ b/django_downloadview/files.py @@ -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): diff --git a/django_downloadview/io.py b/django_downloadview/io.py index 9c3056e..1935413 100644 --- a/django_downloadview/io.py +++ b/django_downloadview/io.py @@ -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 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 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) diff --git a/django_downloadview/test.py b/django_downloadview/test.py index ef0ba00..f72bd66 100644 --- a/django_downloadview/test.py +++ b/django_downloadview/test.py @@ -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: diff --git a/django_downloadview/tests/__init__.py b/django_downloadview/tests/__init__.py deleted file mode 100644 index 0f38c3e..0000000 --- a/django_downloadview/tests/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -"""Unit tests.""" diff --git a/django_downloadview/tests/views.py b/django_downloadview/tests/views.py deleted file mode 100644 index 5ccfb20..0000000 --- a/django_downloadview/tests/views.py +++ /dev/null @@ -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) diff --git a/docs/about/vision.txt b/docs/about/vision.txt index 7b5f8d7..3ba4797 100644 --- a/docs/about/vision.txt +++ b/docs/about/vision.txt @@ -19,8 +19,8 @@ optimizations. * :doc:`/about/alternatives` * `roadmap - `_ + `_ .. target-notes:: -.. _`Django`: https://django-project.com +.. _`Django`: https://djangoproject.com diff --git a/docs/conf.py b/docs/conf.py index ec40789..7b29299 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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 -# " v 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 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' diff --git a/docs/files.txt b/docs/files.txt index 7511040..e7f88bb 100644 --- a/docs/files.txt +++ b/docs/files.txt @@ -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:: diff --git a/docs/overview.txt b/docs/overview.txt index c4ed8b5..503b72e 100644 --- a/docs/overview.txt +++ b/docs/overview.txt @@ -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 diff --git a/docs/settings.txt b/docs/settings.txt index e7d5b88..3e197a0 100644 --- a/docs/settings.txt +++ b/docs/settings.txt @@ -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 `, +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. diff --git a/docs/views/virtual.txt b/docs/views/virtual.txt index d7ea396..e08d4d1 100644 --- a/docs/views/virtual.txt +++ b/docs/views/virtual.txt @@ -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 diff --git a/etc/nose/base.cfg b/etc/nose/base.cfg deleted file mode 100644 index 8165c25..0000000 --- a/etc/nose/base.cfg +++ /dev/null @@ -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 diff --git a/etc/nose/demoproject.cfg b/etc/nose/demoproject.cfg deleted file mode 100644 index d77cfcf..0000000 --- a/etc/nose/demoproject.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[nosetests] -with-coverage = True -cover-package = demoproject -tests = demoproject diff --git a/etc/nose/django_downloadview.cfg b/etc/nose/django_downloadview.cfg deleted file mode 100644 index 70e3c08..0000000 --- a/etc/nose/django_downloadview.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[nosetests] -with-coverage = True -cover-package = django_downloadview -tests = django_downloadview,tests diff --git a/setup.py b/setup.py index 4d86e50..d7e924f 100644 --- a/setup.py +++ b/setup.py @@ -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, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_downloadview/tests/api.py b/tests/api.py similarity index 100% rename from django_downloadview/tests/api.py rename to tests/api.py diff --git a/tests/io.py b/tests/io.py new file mode 100644 index 0000000..d90816f --- /dev/null +++ b/tests/io.py @@ -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) diff --git a/django_downloadview/tests/response.py b/tests/response.py similarity index 100% rename from django_downloadview/tests/response.py rename to tests/response.py diff --git a/tests/sendfile.py b/tests/sendfile.py new file mode 100644 index 0000000..370a5a9 --- /dev/null +++ b/tests/sendfile.py @@ -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) diff --git a/tests/views.py b/tests/views.py index a98e2a9..c6c9517 100644 --- a/tests/views.py +++ b/tests/views.py @@ -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 diff --git a/tox.ini b/tox.ini index 769309a..a1f7caf 100644 --- a/tox.ini +++ b/tox.ini @@ -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