Compare commits

..

No commits in common. "master" and "1.3" have entirely different histories.
master ... 1.3

131 changed files with 1714 additions and 3968 deletions

View file

@ -1,8 +0,0 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"

View file

@ -1,40 +0,0 @@
name: Release
on:
push:
tags:
- '*'
jobs:
build:
if: github.repository == 'jazzband/django-downloadview'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.8
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -U setuptools twine wheel
- name: Build package
run: |
python setup.py --version
python setup.py sdist --format=gztar bdist_wheel
twine check dist/*
- name: Upload packages to Jazzband
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@master
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
repository_url: https://jazzband.co/projects/django-downloadview/upload

View file

@ -1,61 +0,0 @@
name: Test
on: [push, pull_request]
jobs:
build:
name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
django-version: ['4.2', '5.0', '5.1', '5.2', 'main']
exclude:
# Django 5.0 dropped support for Python <3.10
- django-version: '5.0'
python-version: '3.8'
- django-version: '5.0'
python-version: '3.9'
- django-version: 'main'
python-version: '3.8'
- django-version: 'main'
python-version: '3.9'
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Get pip cache dir
id: pip-cache
run: |
echo "::set-output name=dir::$(pip cache dir)"
- name: Cache
uses: actions/cache@v5
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ matrix.python-version }}-v1-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions
- name: Tox tests
run: |
tox -v
env:
DJANGO: ${{ matrix.django-version }}
- name: Upload coverage
uses: codecov/codecov-action@v6
with:
name: Python ${{ matrix.python-version }}

15
.gitignore vendored
View file

@ -6,27 +6,12 @@
# Data files. # Data files.
/var/ /var/
coverage.xml
.coverage/
# Python files. # Python files.
*.pyc *.pyc
*.pyo *.pyo
*.egg-info *.egg-info
# Tox files.
/.tox/
.eggs
*.egg-info
# Virtualenv files (created by tox).
/build/
/dist/
# Virtual environments (created by user).
/venv/
# Editors' temporary buffers. # Editors' temporary buffers.
.*.swp .*.swp
*~ *~
.idea

View file

@ -1,16 +0,0 @@
[settings]
# # Needed for black compatibility
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
line_length=88
combine_as_imports=True
# List sections with django and
known_django=django
known_downloadview=django_downloadview
sections=FUTURE,STDLIB,DJANGO,DOWNLOADVIEW,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
# If set, imports will be sorted within their section independent to the import_type.
force_sort_within_sections=True

View file

@ -1,59 +0,0 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- id: mixed-line-ending
- id: file-contents-sorter
files: docs/spelling_wordlist.txt
- repo: https://github.com/pycqa/doc8
rev: v2.0.0
hooks:
- id: doc8
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.30.0
hooks:
- id: django-upgrade
args: [--target-version, "4.2"]
- repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.10.0
hooks:
- id: rst-backticks
- id: rst-directive-colons
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
entry: env PRETTIER_LEGACY_CLI=1 prettier
types_or: [javascript, css]
args:
- --trailing-comma=es5
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v10.3.0
hooks:
- id: eslint
additional_dependencies:
- "eslint@v9.0.0-beta.1"
- "@eslint/js@v9.0.0-beta.1"
- "globals"
files: \.js?$
types: [file]
args:
- --fix
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.15.12'
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.21.1
hooks:
- id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.25
hooks:
- id: validate-pyproject

View file

@ -1,18 +0,0 @@
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.10"
sphinx:
configuration: docs/conf.py
python:
install:
- method: pip
path: .

5
.travis.yml Normal file
View file

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

27
AUTHORS
View file

@ -4,25 +4,12 @@ Authors & contributors
Maintainer: Benoît Bryon <benoit@marmelune.net> Maintainer: Benoît Bryon <benoit@marmelune.net>
Original code by `PeopleDoc <https://www.people-doc.com/>`_ team: Original code by `Novapost <http://www.novapost.fr>`_ team:
* Adam Chainz <adam@adamj.eu> * Nicolas Tobo <https://github.com/nicolastobo>
* Aleksi Häkli <aleksi.hakli@iki.fi> * Lauréline Guérin <https://github.com/zebuline>
* Gregory Tappero <https://github.com/coulix>
* Rémy Hubscher <remy.hubscher@novapost.fr>
* Benoît Bryon <benoit@marmelune.net> * Benoît Bryon <benoit@marmelune.net>
* CJ <cjdreiss@users.noreply.github.com>
* David Wolf <68775926+devidw@users.noreply.github.com> Developers: https://github.com/benoitbryon/django-downloadview/graphs/contributors
* Davide Setti <setti.davide89@gmail.com>
* Erik Dykema <dykema@gmail.com>
* Fabre Florian <ffabre@hybird.org>
* Hasan Ramezani <hasan.r67@gmail.com>
* Jannis Leidel <jannis@leidel.info>
* John Hagen <johnthagen@gmail.com>
* Mariusz Felisiak <felisiak.mariusz@gmail.com>
* Martin Bächtold <martin@baechtold.me>
* Nikhil Benesch <nikhil.benesch@gmail.com>
* Omer Katz <omer.drow@gmail.com>
* Peter Marheine <peter@taricorp.net>
* René Leonhardt <rene.leonhardt@gmail.com>
* Rémy HUBSCHER <hubscher.remy@gmail.com>
* Tim Gates <tim.gates@iress.com>
* zero13cool <zero13cool@yandex.ru>

154
CHANGELOG
View file

@ -4,158 +4,6 @@ Changelog
This document describes changes between past releases. For information about This document describes changes between past releases. For information about
future releases, check `milestones`_ and :doc:`/about/vision`. future releases, check `milestones`_ and :doc:`/about/vision`.
2.6.0 (unreleased)
----------------
- No changes yet
2.5.0 (2025-10-28)
----------------
- Upgrade to Django 5.2 and Python 3.14
2.4.0 (2024-08-05)
------------------
- Drop support for Python 3.6
- Escape malicious filenames
- Handle headers in XAccel responses
2.3.0 (2022-01-11)
------------------
- Drop Django 3.0 support
- Add Django 3.2 support
- Add support for Python 3.10
- Add support for Django 4.0
- Remove support for Python 3.5 and Django 1.11
- Add support for Python 3.9 and Django 3.1
- Remove old urls syntax and adopt the new one
- Move the project to the jazzband organization
- Adopt black automatic formatting rules
2.1.1 (2020-01-14)
------------------
- Fix missing function parameter. (#152)
2.1 (2020-01-13)
----------------
- Add a SignedFileSystemStorage that signs file URLs for clients. (#151)
2.0 (2020-01-07)
----------------
- Drop support for Python 2.7.
- Add black and isort.
1.10 (2020-01-07)
-----------------
- Introduced support from Django 1.11, 2.2 and 3.0.
- Drop support of Django 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 2.0 and 2.1
1.9 (2016-03-15)
----------------
- Feature #112 - Introduced support of Django 1.9.
- Feature #113 - Introduced support of Python 3.5.
- Feature #116 - ``HTTPFile`` has ``content_type`` property. It makes
``HTTPDownloadView`` proxy ``Content-Type`` header from remote location.
1.8 (2015-07-20)
----------------
Bugfixes.
- Bugfix #103 - ``PathDownloadView.get_file()`` makes a single call to
``PathDownloadView.get_file()`` (was doing it twice).
- Bugfix #104 - Pass numeric timestamp to Django's ``was_modified_since()``
(was passing a datetime).
1.7 (2015-06-13)
----------------
Bugfixes.
- Bugfix #87 - Filenames with commas are now supported. In download responses,
filename is now surrounded by double quotes.
- 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``.
- Bugfix #92 - Run demo using ``make demo runserver`` (was broken).
- 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)
----------------
Python 3 support, development environment refactoring.
- Feature #46: introduced support for Python>=3.3.
- Feature #80: added documentation about "how to serve a file inline VS how to
serve a file as attachment". Improved documentation of views' base options
inherited from ``DownloadMixin``.
- Feature #74: the Makefile in project's repository no longer creates a
virtualenv. Developers setup the environment as they like, i.e. using
virtualenv, virtualenvwrapper or whatever. Tests are run with tox.
1.5 (2013-11-29)
----------------
X-Sendfile support and helpers to migrate for `django-sendfile`.
- Feature #2 - Introduced support of Lighttpd's x-Sendfile.
- Feature #36 - Introduced support of Apache's mod_xsendfile.
- Feature #41 - ``django_downloadview.sendfile`` is a port of
`django-sendfile`'s ``sendfile`` function. The documentation contains notes
about migrating from `django-sendfile` to `django-downloadview`.
1.4 (2013-11-24)
----------------
Bugfixes and documentation features.
- Bugfix #43 - ``ObjectDownloadView`` returns HTTP 404 if model instance's file
field is empty (was HTTP 500).
- Bugfix #7 - Special characters in file names (``Content-Disposition`` header)
are urlencoded. An US-ASCII fallback is also provided.
- Feature #10 - `django-downloadview` is registered on djangopackages.com.
- Feature #65 - INSTALL documentation shows "known good set" (KGS) of versions,
i.e. versions that have been used in test environment.
1.3 (2013-11-08) 1.3 (2013-11-08)
---------------- ----------------
@ -266,4 +114,4 @@ Contains **backward incompatible changes.**
.. target-notes:: .. target-notes::
.. _`milestones`: https://github.com/jazzband/django-downloadview/milestones .. _`milestones`: https://github.com/benoitbryon/django-downloadview/issues/milestones

View file

@ -1,46 +0,0 @@
# Code of Conduct
As contributors and maintainers of the Jazzband projects, and in the interest of
fostering an open and welcoming community, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating documentation,
submitting pull requests or patches, and other activities.
We are committed to making participation in the Jazzband a harassment-free experience
for everyone, regardless of the level of experience, gender, gender identity and
expression, sexual orientation, disability, personal appearance, body size, race,
ethnicity, age, religion, or nationality.
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery
- Personal attacks
- Trolling or insulting/derogatory comments
- Public or private harassment
- Publishing other's private information, such as physical or electronic addresses,
without explicit permission
- Other unethical or unprofessional conduct
The Jazzband roadies have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are not
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
By adopting this Code of Conduct, the roadies commit themselves to fairly and
consistently applying these principles to every aspect of managing the jazzband
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
removed from the Jazzband roadies.
This code of conduct applies both within project spaces and in public spaces when an
individual is representing the project or its community.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
investigated and will result in a response that is deemed necessary and appropriate to
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
reporter of an incident.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
[homepage]: https://contributor-covenant.org
[version]: https://contributor-covenant.org/version/1/3/0/

View file

@ -1,102 +0,0 @@
############
Contributing
############
.. image:: https://jazzband.co/static/img/jazzband.svg
:target: https://jazzband.co/
:alt: Jazzband
This is a `Jazzband <https://jazzband.co>`_ project. By contributing you agree to abide by the `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_ and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
This document provides guidelines for people who want to contribute to
``django-downloadview``.
**************
Create tickets
**************
Please use the `bugtracker`_ **before** starting some work:
* check if the bug or feature request has already been filed. It may have been
answered too!
* else create a new ticket.
* if you plan to contribute, tell us, so that we are given an opportunity to
give feedback as soon as possible.
* Then, in your commit messages, reference the ticket with some
``refs #TICKET-ID`` syntax.
******************
Use topic branches
******************
* Work in branches.
* Prefix your branch with the ticket ID corresponding to the issue. As an
example, if you are working on ticket #23 which is about contribute
documentation, name your branch like ``23-contribute-doc``.
* If you work in a development branch and want to refresh it with changes from
master, please `rebase`_ or `merge-based rebase`_, i.e. do not merge master.
***********
Fork, clone
***********
Clone ``django-downloadview`` repository (adapt to use your own fork):
.. code:: sh
git clone git@github.com:jazzband/django-downloadview.git
cd django-downloadview/
*************
Usual actions
*************
The ``Makefile`` is the reference card for usual actions in development
environment:
* Install development toolkit with `pip`_: ``make develop``.
* Run tests with `tox`_: ``make test``.
* Build documentation: ``make documentation``. It builds `Sphinx`_
documentation in ``var/docs/html/index.html``.
* Release project with `zest.releaser`_: ``make release``.
* Cleanup local repository: ``make clean``, ``make distclean`` and
``make maintainer-clean``.
See also ``make help``.
*********************
Demo project included
*********************
The ``demo`` included in project's repository is part of the tests and
documentation. Maintain it along with code and documentation.
.. rubric:: Notes & references
.. target-notes::
.. _`bugtracker`:
https://github.com/jazzband/django-downloadview/issues
.. _`rebase`: http://git-scm.com/book/en/Git-Branching-Rebasing
.. _`merge-based rebase`: https://tech.people-doc.com/psycho-rebasing.html
.. _`pip`: https://pypi.python.org/pypi/pip/
.. _`tox`: https://tox.readthedocs.io/
.. _`Sphinx`: https://pypi.python.org/pypi/Sphinx/
.. _`zest.releaser`: https://pypi.python.org/pypi/zest.releaser/

72
INSTALL
View file

@ -4,74 +4,23 @@ Install
.. note:: .. note::
If you want to install a development environment, please see If you want to install a development environment, please see :doc:`/dev`.
:doc:`/contributing`.
System requirements:
************ * Python 2.7
Requirements
************
`django-downloadview` has been tested with `Python`_ 3.7, 3.8, 3.9 and 3.10. Install the package with your favorite Python installer. As an example, with
Other versions may work, but they are not part of the test suite at the moment. pip:
Installing `django-downloadview` will automatically trigger the installation of
the following requirements:
.. literalinclude:: /../setup.py
:language: python
:start-after: BEGIN requirements
:end-before: END requirements
************
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 .. code:: sh
pip install django-downloadview pip install django-downloadview
.. note::
***** Since version 1.1, django-downloadview requires Django>=1.5, which provides
Check :py:class:`~django.http.StreamingHttpResponse`.
*****
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 .. rubric:: Notes & references
@ -81,8 +30,3 @@ You should get installed `django-downloadview`'s version.
* :doc:`/settings` * :doc:`/settings`
* :doc:`/about/changelog` * :doc:`/about/changelog`
* :doc:`/about/license` * :doc:`/about/license`
.. target-notes::
.. _`Python`: https://www.python.org/
.. _`pip`: https://pip.pypa.io/

View file

@ -2,7 +2,7 @@
License License
####### #######
Copyright (c) 2012-2014, Benoît Bryon. Copyright (c) 2012-2013, Benoît Bryon.
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without

View file

@ -1,8 +1,4 @@
recursive-include django_downloadview * recursive-include django_downloadview *
global-exclude *.pyc global-exclude *.pyc .*.swp
include AUTHORS include *.txt
include CHANGELOG include AUTHORS CHANGELOG INSTALL LICENSE README VERSION
include CONTRIBUTING.rst
include INSTALL
include LICENSE
include README.rst

136
Makefile
View file

@ -1,101 +1,89 @@
# Reference card for usual actions in development environment. # Makefile for development.
# # See INSTALL and docs/dev.txt for details.
# For standard installation of django-downloadview as a library, see INSTALL. SHELL = /bin/bash
# ROOT_DIR = $(shell pwd)
# For details about django-downloadview's development environment, see BIN_DIR = $(ROOT_DIR)/bin
# CONTRIBUTING.rst. DATA_DIR = $(ROOT_DIR)/var
# WGET = wget
PIP = pip PYTHON = $(shell which python)
TOX = tox PROJECT = $(shell $(PYTHON) -c "import setup; print setup.NAME")
BLACK = black PACKAGE = $(shell $(PYTHON) -c "import setup; print setup.PACKAGES[0]")
ISORT = isort BUILDOUT_CFG = $(ROOT_DIR)/etc/buildout.cfg
BUILDOUT_DIR = $(ROOT_DIR)/lib/buildout
#: help - Display callable targets. BUILDOUT_VERSION = 1.7.0
.PHONY: help BUILDOUT_BOOTSTRAP_URL = https://raw.github.com/buildout/buildout/$(BUILDOUT_VERSION)/bootstrap/bootstrap.py
help: BUILDOUT_BOOTSTRAP = $(BUILDOUT_DIR)/bootstrap.py
@echo "Reference card for usual actions in development environment." BUILDOUT_BOOTSTRAP_ARGS = -c $(BUILDOUT_CFG) --version=$(BUILDOUT_VERSION) --distribute buildout:directory=$(ROOT_DIR)
@echo "Here are available targets:" BUILDOUT = $(BIN_DIR)/buildout
@egrep -o "^#: (.+)" [Mm]akefile | sed 's/#: /* /' BUILDOUT_ARGS = -N -c $(BUILDOUT_CFG) buildout:directory=$(ROOT_DIR)
NOSE = $(BIN_DIR)/nosetests
#: develop - Install minimal development utilities. configure:
.PHONY: develop # Configuration is stored in etc/ folder. Not generated yet.
develop:
$(PIP) install -e .
develop: buildout
buildout:
if [ ! -d $(BUILDOUT_DIR) ]; then mkdir -p $(BUILDOUT_DIR); fi
if [ ! -f $(BUILDOUT_BOOTSTRAP) ]; then wget -O $(BUILDOUT_BOOTSTRAP) $(BUILDOUT_BOOTSTRAP_URL); fi
if [ ! -x $(BUILDOUT) ]; then $(PYTHON) $(BUILDOUT_BOOTSTRAP) $(BUILDOUT_BOOTSTRAP_ARGS); fi
$(BUILDOUT) $(BUILDOUT_ARGS)
#: clean - Basic cleanup, mostly temporary files.
.PHONY: clean
clean: clean:
find . -name "*.pyc" -delete find $(ROOT_DIR)/ -name "*.pyc" -delete
find . -name '*.pyo' -delete find $(ROOT_DIR)/ -name ".noseids" -delete
find . -name "__pycache__" -delete
#: distclean - Remove local builds, such as *.egg-info.
.PHONY: distclean
distclean: clean distclean: clean
rm -rf *.egg rm -rf $(ROOT_DIR)/*.egg-info
rm -rf *.egg-info rm -rf $(ROOT_DIR)/demo/*.egg-info
rm -rf demo/*.egg-info
#: maintainer-clean - Remove almost everything that can be re-generated.
.PHONY: maintainer-clean
maintainer-clean: distclean maintainer-clean: distclean
rm -rf build/ rm -rf $(BIN_DIR)/
rm -rf dist/ rm -rf $(ROOT_DIR)/lib/
rm -rf .tox/
#: test - Run test suites. test: test-app test-demo test-documentation
.PHONY: test
test:
mkdir -p var
$(PIP) install -e .[test]
$(TOX)
#: documentation - Build documentation (Sphinx, README, ...) test-app:
.PHONY: documentation $(NOSE) -c $(ROOT_DIR)/etc/nose/base.cfg -c $(ROOT_DIR)/etc/nose/$(PACKAGE).cfg
documentation: sphinx readme mv $(ROOT_DIR)/.coverage $(ROOT_DIR)/var/test/app.coverage
test-demo:
$(BIN_DIR)/demo test --nose-verbosity=2
mv $(ROOT_DIR)/.coverage $(ROOT_DIR)/var/test/demo.coverage
test-documentation:
$(NOSE) -c $(ROOT_DIR)/etc/nose/base.cfg sphinxcontrib.testbuild.tests
#: sphinx - Build Sphinx documentation (docs).
.PHONY: sphinx
sphinx: sphinx:
$(TOX) -e sphinx make --directory=docs clean html doctest
#: readme - Build standalone documentation files (README, CONTRIBUTING...). documentation: sphinx
.PHONY: readme
readme:
$(TOX) -e readme
#: demo - Setup demo project. demo: develop
.PHONY: demo $(BIN_DIR)/demo syncdb --noinput
demo:
pip install -e .
pip install -e demo
demo migrate --noinput
# Install fixtures. # Install fixtures.
mkdir -p var/media/object var/media/object-other/ var/media/nginx mkdir -p var/media
cp -r demo/demoproject/fixtures/* var/media/object/ cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/object
cp -r demo/demoproject/fixtures/* var/media/object-other/ cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/object-other
cp -r demo/demoproject/fixtures/* var/media/nginx/ cp -r $(ROOT_DIR)/demo/demoproject/fixtures var/media/nginx
demo loaddata demo.json $(BIN_DIR)/demo loaddata demo.json
#: runserver - Run demo server.
.PHONY: runserver
runserver: demo runserver: demo
demo runserver $(BIN_DIR)/demo runserver
.PHONY: black
black:
$(BLACK) demo tests django_downloadview
.PHONY: isort release:
isort: $(BIN_DIR)/fullrelease
$(ISORT) --recursive django_downloadview tests demo

View file

@ -2,33 +2,9 @@
django-downloadview django-downloadview
################### ###################
.. image:: https://jazzband.co/static/img/badge.svg ``django-downloadview`` makes it easy to serve files with Django:
:target: https://jazzband.co/
:alt: Jazzband
.. image:: https://img.shields.io/pypi/v/django-downloadview.svg * you manage files with Django (permissions, search, generation, ...);
:target: https://pypi.python.org/pypi/django-downloadview
.. image:: https://img.shields.io/pypi/pyversions/django-downloadview.svg
:target: https://pypi.python.org/pypi/django-downloadview
.. image:: https://img.shields.io/pypi/djversions/django-downloadview.svg
:target: https://pypi.python.org/pypi/django-downloadview
.. image:: https://img.shields.io/pypi/dm/django-downloadview.svg
:target: https://pypi.python.org/pypi/django-downloadview
.. image:: https://github.com/jazzband/django-downloadview/workflows/Test/badge.svg
:target: https://github.com/jazzband/django-downloadview/actions
:alt: GitHub Actions
.. image:: https://codecov.io/gh/jazzband/django-downloadview/branch/master/graph/badge.svg
:target: https://codecov.io/gh/jazzband/django-downloadview
:alt: Coverage
``django-downloadview`` makes it easy to serve files with `Django`_:
* you manage files with Django (permissions, filters, generation, ...);
* files are stored somewhere or generated somehow (local filesystem, remote * files are stored somewhere or generated somehow (local filesystem, remote
storage, memory...); storage, memory...);
@ -36,14 +12,14 @@ django-downloadview
* ``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, * ``django-downloadview`` helps you improve performances with reverse proxies,
via mechanisms such as Nginx's X-Accel or Apache's X-Sendfile. via mechanisms such as Nginx's X-Accel.
******* *******
Example Example
******* *******
Let's serve a file stored in a file field of some model: Let's serve a file stored in a FileField of some model:
.. code:: python .. code:: python
@ -59,15 +35,13 @@ Let's serve a file stored in a file field of some model:
) )
********* **********
Resources Ressources
********* **********
* Documentation: https://django-downloadview.readthedocs.io * Documentation: http://django-downloadview.readthedocs.org
* PyPI page: http://pypi.python.org/pypi/django-downloadview * PyPI page: http://pypi.python.org/pypi/django-downloadview
* Code repository: https://github.com/jazzband/django-downloadview * Code repository: https://github.com/benoitbryon/django-downloadview
* Bugtracker: https://github.com/jazzband/django-downloadview/issues * Bugtracker: https://github.com/benoitbryon/django-downloadview/issues
* Continuous integration: https://github.com/jazzband/django-downloadview/actions * Continuous integration: https://travis-ci.org/benoitbryon/django-downloadview
* Roadmap: https://github.com/jazzband/django-downloadview/milestones * Roadmap: https://github.com/benoitbryon/django-downloadview/issues/milestones
.. _`Django`: https://djangoproject.com

1
VERSION Normal file
View file

@ -0,0 +1 @@
1.3

View file

@ -3,7 +3,7 @@ Demo project
############ ############
`Demo folder in project's repository`_ contains a Django project to illustrate `Demo folder in project's repository`_ contains a Django project to illustrate
``django-downloadview`` usage. `django-downloadview` usage.
***************************************** *****************************************
@ -31,8 +31,8 @@ Deploy the demo
System requirements: System requirements:
* `Python`_ version 3.7+, available as ``python`` command. * `Python`_ version 2.7, available as ``python`` command.
.. note:: .. note::
You may use `Virtualenv`_ to make sure the active ``python`` is the right You may use `Virtualenv`_ to make sure the active ``python`` is the right
@ -44,12 +44,12 @@ Execute:
.. code-block:: sh .. code-block:: sh
git clone git@github.com:jazzband/django-downloadview.git git clone git@github.com:benoitbryon/django-downloadview.git
cd django-downloadview/ cd django-downloadview/
make runserver make runserver
It installs and runs the demo server on localhost, port 8000. So have a look It installs and runs the demo server on localhost, port 8000. So have a look
at ``http://localhost:8000/``. at http://localhost:8000/
.. note:: .. note::
@ -66,7 +66,7 @@ References
.. target-notes:: .. target-notes::
.. _`demo folder in project's repository`: .. _`demo folder in project's repository`:
https://github.com/jazzband/django-downloadview/tree/master/demo/demoproject/ https://github.com/benoitbryon/django-downloadview/tree/master/demo/demoproject/
.. _`Python`: http://python.org .. _`Python`: http://python.org
.. _`Virtualenv`: http://virtualenv.org .. _`Virtualenv`: http://virtualenv.org

View file

@ -1 +0,0 @@
"""Apache optimizations."""

View file

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

View file

@ -1,61 +0,0 @@
import os
from django.core.files.base import ContentFile
import django.test
from django.urls import reverse
from django_downloadview.apache import assert_x_sendfile
from demoproject.apache.views import storage, storage_dir
def setup_file():
if not os.path.exists(storage_dir):
os.makedirs(storage_dir)
storage.save("hello-world.txt", ContentFile("Hello world!\n"))
class OptimizedByMiddlewareTestCase(django.test.TestCase):
def test_response(self):
"""'apache:optimized_by_middleware' returns X-Sendfile response."""
setup_file()
url = reverse("apache:optimized_by_middleware")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/apache-optimized-by-middleware/hello-world.txt",
)
class OptimizedByDecoratorTestCase(django.test.TestCase):
def test_response(self):
"""'apache:optimized_by_decorator' returns X-Sendfile response."""
setup_file()
url = reverse("apache:optimized_by_decorator")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/apache-optimized-by-decorator/hello-world.txt",
)
class ModifiedHeadersTestCase(django.test.TestCase):
def test_response(self):
"""'apache:modified_headers' returns X-Sendfile response."""
setup_file()
url = reverse("apache:modified_headers")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/apache-modified-headers/hello-world.txt",
)
self.assertEqual(response["X-Test"], "header")

View file

@ -1,24 +0,0 @@
"""URL mapping."""
from django.urls import path
from demoproject.apache import views
app_name = "apache"
urlpatterns = [
path(
"optimized-by-middleware/",
views.optimized_by_middleware,
name="optimized_by_middleware",
),
path(
"optimized-by-decorator/",
views.optimized_by_decorator,
name="optimized_by_decorator",
),
path(
"modified_headers/",
views.modified_headers,
name="modified_headers",
),
]

View file

@ -1,38 +0,0 @@
import os
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django_downloadview import StorageDownloadView
from django_downloadview.apache import x_sendfile
storage_dir = os.path.join(settings.MEDIA_ROOT, "apache")
storage = FileSystemStorage(
location=storage_dir, base_url="".join([settings.MEDIA_URL, "apache/"])
)
optimized_by_middleware = StorageDownloadView.as_view(
storage=storage, path="hello-world.txt"
)
optimized_by_decorator = x_sendfile(
StorageDownloadView.as_view(storage=storage, path="hello-world.txt"),
source_url=storage.base_url,
destination_dir="/apache-optimized-by-decorator/",
)
def _modified_headers(request):
view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt")
response = view(request)
response["X-Test"] = "header"
return response
modified_headers = x_sendfile(
_modified_headers,
source_url=storage.base_url,
destination_dir="/apache-modified-headers/",
)

View file

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

View file

@ -1,5 +1,5 @@
from django.core.urlresolvers import reverse
import django.test import django.test
from django.urls import reverse
from django_downloadview import assert_download_response from django_downloadview import assert_download_response
@ -7,20 +7,10 @@ from django_downloadview import assert_download_response
class SimpleURLTestCase(django.test.TestCase): class SimpleURLTestCase(django.test.TestCase):
def test_download_response(self): def test_download_response(self):
"""'simple_url' serves 'hello-world.txt' from Github.""" """'simple_url' serves 'hello-world.txt' from Github."""
url = reverse("http:simple_url") url = reverse('http:simple_url')
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content='Hello world!\n',
content="Hello world!\n", basename='hello-world.txt',
basename="hello-world.txt", mime_type='text/plain')
mime_type="text/plain",
)
class AvatarTestCase(django.test.TestCase):
def test_download_response(self):
"""HTTPDownloadView proxies Content-Type header."""
url = reverse("http:avatar_url")
response = self.client.get(url)
assert_download_response(self, response, mime_type="image/png")

View file

@ -1,9 +1,11 @@
from django.urls import path from django.conf.urls import patterns, url
from demoproject.http import views from demoproject.http import views
app_name = "http"
urlpatterns = [ urlpatterns = patterns(
path("simple_url/", views.simple_url, name="simple_url"), '',
path("avatar_url/", views.avatar_url, name="avatar_url"), url(r'^simple_url/$',
] views.simple_url,
name='simple_url'),
)

View file

@ -4,18 +4,9 @@ from django_downloadview import HTTPDownloadView
class SimpleURLDownloadView(HTTPDownloadView): class SimpleURLDownloadView(HTTPDownloadView):
def get_url(self): def get_url(self):
"""Return URL of hello-world.txt file on GitHub.""" """Return URL of hello-world.txt file on GitHub."""
return ( return 'https://raw.github.com/benoitbryon/django-downloadview' \
"https://raw.githubusercontent.com" '/b7f660c5e3f37d918b106b02c5af7a887acc0111' \
"/jazzband/django-downloadview" '/demo/demoproject/download/fixtures/hello-world.txt'
"/b7f660c5e3f37d918b106b02c5af7a887acc0111"
"/demo/demoproject/download/fixtures/hello-world.txt"
)
class GithubAvatarDownloadView(HTTPDownloadView):
def get_url(self):
return "https://avatars0.githubusercontent.com/u/235204"
simple_url = SimpleURLDownloadView.as_view() simple_url = SimpleURLDownloadView.as_view()
avatar_url = GithubAvatarDownloadView.as_view()

View file

@ -1 +0,0 @@
"""Lighttpd optimizations."""

View file

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

View file

@ -1,61 +0,0 @@
import os
from django.core.files.base import ContentFile
import django.test
from django.urls import reverse
from django_downloadview.lighttpd import assert_x_sendfile
from demoproject.lighttpd.views import storage, storage_dir
def setup_file():
if not os.path.exists(storage_dir):
os.makedirs(storage_dir)
storage.save("hello-world.txt", ContentFile("Hello world!\n"))
class OptimizedByMiddlewareTestCase(django.test.TestCase):
def test_response(self):
"""'lighttpd:optimized_by_middleware' returns X-Sendfile response."""
setup_file()
url = reverse("lighttpd:optimized_by_middleware")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/lighttpd-optimized-by-middleware/hello-world.txt",
)
class OptimizedByDecoratorTestCase(django.test.TestCase):
def test_response(self):
"""'lighttpd:optimized_by_decorator' returns X-Sendfile response."""
setup_file()
url = reverse("lighttpd:optimized_by_decorator")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/lighttpd-optimized-by-decorator/hello-world.txt",
)
class ModifiedHeadersTestCase(django.test.TestCase):
def test_response(self):
"""'lighttpd:modified_headers' returns X-Sendfile response."""
setup_file()
url = reverse("lighttpd:modified_headers")
response = self.client.get(url)
assert_x_sendfile(
self,
response,
content_type="text/plain; charset=utf-8",
basename="hello-world.txt",
file_path="/lighttpd-modified-headers/hello-world.txt",
)
self.assertEqual(response["X-Test"], "header")

View file

@ -1,24 +0,0 @@
"""URL mapping."""
from django.urls import path
from demoproject.lighttpd import views
app_name = "lighttpd"
urlpatterns = [
path(
"optimized-by-middleware/",
views.optimized_by_middleware,
name="optimized_by_middleware",
),
path(
"optimized-by-decorator/",
views.optimized_by_decorator,
name="optimized_by_decorator",
),
path(
"modified_headers/",
views.modified_headers,
name="modified_headers",
),
]

View file

@ -1,38 +0,0 @@
import os
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django_downloadview import StorageDownloadView
from django_downloadview.lighttpd import x_sendfile
storage_dir = os.path.join(settings.MEDIA_ROOT, "lighttpd")
storage = FileSystemStorage(
location=storage_dir, base_url="".join([settings.MEDIA_URL, "lighttpd/"])
)
optimized_by_middleware = StorageDownloadView.as_view(
storage=storage, path="hello-world.txt"
)
optimized_by_decorator = x_sendfile(
StorageDownloadView.as_view(storage=storage, path="hello-world.txt"),
source_url=storage.base_url,
destination_dir="/lighttpd-optimized-by-decorator/",
)
def _modified_headers(request):
view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt")
response = view(request)
response["X-Test"] = "header"
return response
modified_headers = x_sendfile(
_modified_headers,
source_url=storage.base_url,
destination_dir="/lighttpd-modified-headers/",
)

View file

@ -4,9 +4,8 @@ import sys
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
def main(): def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", f"{__package__}.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "%s.settings" % __package__)
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)

View file

@ -1,8 +1,8 @@
import os import os
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.urlresolvers import reverse
import django.test import django.test
from django.urls import reverse
from django_downloadview.nginx import assert_x_accel_redirect from django_downloadview.nginx import assert_x_accel_redirect
@ -12,14 +12,14 @@ from demoproject.nginx.views import storage, storage_dir
def setup_file(): def setup_file():
if not os.path.exists(storage_dir): if not os.path.exists(storage_dir):
os.makedirs(storage_dir) os.makedirs(storage_dir)
storage.save("hello-world.txt", ContentFile("Hello world!\n")) storage.save('hello-world.txt', ContentFile(u'Hello world!\n'))
class OptimizedByMiddlewareTestCase(django.test.TestCase): class OptimizedByMiddlewareTestCase(django.test.TestCase):
def test_response(self): def test_response(self):
"""'nginx:optimized_by_middleware' returns X-Accel response.""" """'nginx:optimized_by_middleware' returns X-Accel response."""
setup_file() setup_file()
url = reverse("nginx:optimized_by_middleware") url = reverse('nginx:optimized_by_middleware')
response = self.client.get(url) response = self.client.get(url)
assert_x_accel_redirect( assert_x_accel_redirect(
self, self,
@ -30,15 +30,14 @@ class OptimizedByMiddlewareTestCase(django.test.TestCase):
redirect_url="/nginx-optimized-by-middleware/hello-world.txt", redirect_url="/nginx-optimized-by-middleware/hello-world.txt",
expires=None, expires=None,
with_buffering=None, with_buffering=None,
limit_rate=None, limit_rate=None)
)
class OptimizedByDecoratorTestCase(django.test.TestCase): class OptimizedByDecoratorTestCase(django.test.TestCase):
def test_response(self): def test_response(self):
"""'nginx:optimized_by_decorator' returns X-Accel response.""" """'nginx:optimized_by_decorator' returns X-Accel response."""
setup_file() setup_file()
url = reverse("nginx:optimized_by_decorator") url = reverse('nginx:optimized_by_decorator')
response = self.client.get(url) response = self.client.get(url)
assert_x_accel_redirect( assert_x_accel_redirect(
self, self,
@ -49,25 +48,4 @@ class OptimizedByDecoratorTestCase(django.test.TestCase):
redirect_url="/nginx-optimized-by-decorator/hello-world.txt", redirect_url="/nginx-optimized-by-decorator/hello-world.txt",
expires=None, expires=None,
with_buffering=None, with_buffering=None,
limit_rate=None, limit_rate=None)
)
class ModifiedHeadersTestCase(django.test.TestCase):
def test_response(self):
"""'nginx:modified_headers' returns X-Sendfile response."""
setup_file()
url = reverse("nginx:modified_headers")
response = self.client.get(url)
assert_x_accel_redirect(
self,
response,
content_type="text/plain; charset=utf-8",
charset="utf-8",
basename="hello-world.txt",
redirect_url="/nginx-modified-headers/hello-world.txt",
expires=None,
with_buffering=None,
limit_rate=None,
)
self.assertEqual(response["X-Test"], "header")

View file

@ -1,24 +1,13 @@
"""URL mapping.""" """URL mapping."""
from django.conf.urls import patterns, url
from django.urls import path
from demoproject.nginx import views urlpatterns = patterns(
'demoproject.nginx.views',
app_name = "nginx" url(r'^optimized-by-middleware/$',
urlpatterns = [ 'optimized_by_middleware',
path( name='optimized_by_middleware'),
"optimized-by-middleware/", url(r'^optimized-by-decorator/$',
views.optimized_by_middleware, 'optimized_by_decorator',
name="optimized_by_middleware", name='optimized_by_decorator'),
), )
path(
"optimized-by-decorator/",
views.optimized_by_decorator,
name="optimized_by_decorator",
),
path(
"modified_headers/",
views.modified_headers,
name="modified_headers",
),
]

View file

@ -6,33 +6,17 @@ from django.core.files.storage import FileSystemStorage
from django_downloadview import StorageDownloadView from django_downloadview import StorageDownloadView
from django_downloadview.nginx import x_accel_redirect from django_downloadview.nginx import x_accel_redirect
storage_dir = os.path.join(settings.MEDIA_ROOT, "nginx")
storage = FileSystemStorage( storage_dir = os.path.join(settings.MEDIA_ROOT, 'nginx')
location=storage_dir, base_url="".join([settings.MEDIA_URL, "nginx/"]) storage = FileSystemStorage(location=storage_dir,
) base_url=''.join([settings.MEDIA_URL, 'nginx/']))
optimized_by_middleware = StorageDownloadView.as_view( optimized_by_middleware = StorageDownloadView.as_view(storage=storage,
storage=storage, path="hello-world.txt" path='hello-world.txt')
)
optimized_by_decorator = x_accel_redirect( optimized_by_decorator = x_accel_redirect(
StorageDownloadView.as_view(storage=storage, path="hello-world.txt"), StorageDownloadView.as_view(storage=storage, path='hello-world.txt'),
source_url=storage.base_url, source_url=storage.base_url,
destination_url="/nginx-optimized-by-decorator/", destination_url='/nginx-optimized-by-decorator/')
)
def _modified_headers(request):
view = StorageDownloadView.as_view(storage=storage, path="hello-world.txt")
response = view(request)
response["X-Test"] = "header"
return response
modified_headers = x_accel_redirect(
_modified_headers,
source_url=storage.base_url,
destination_url="/nginx-modified-headers/",
)

View file

@ -3,6 +3,6 @@ from django.db import models
class Document(models.Model): class Document(models.Model):
slug = models.SlugField() slug = models.SlugField()
file = models.FileField(upload_to="object") file = models.FileField(upload_to='object')
another_file = models.FileField(upload_to="object-other") another_file = models.FileField(upload_to='object-other')
basename = models.CharField(max_length=100) basename = models.CharField(max_length=100)

View file

@ -1,24 +1,29 @@
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.urlresolvers import reverse
import django.test import django.test
from django.urls import reverse
from django_downloadview import assert_download_response, temporary_media_root from django_downloadview import temporary_media_root, assert_download_response
from demoproject.object.models import Document from demoproject.object.models import Document
# Fixtures. # Fixtures.
slug = "hello-world" slug = 'hello-world'
basename = "hello-world.txt" basename = 'hello-world.txt'
file_name = "file.txt" file_name = 'file.txt'
another_name = "another_file.txt" another_name = 'another_file.txt'
file_content = "Hello world!\n" file_content = 'Hello world!\n'
another_content = "Goodbye world!\n" another_content = 'Goodbye world!\n'
def setup_document(): def setup_document():
document = Document(slug=slug, basename=basename) document = Document(slug=slug, basename=basename)
document.file.save(file_name, ContentFile(file_content), save=False) document.file.save(file_name,
document.another_file.save(another_name, ContentFile(another_content), save=False) ContentFile(file_content),
save=False)
document.another_file.save(another_name,
ContentFile(another_content),
save=False)
document.save() document.save()
return document return document
@ -28,15 +33,13 @@ class DefaultFileTestCase(django.test.TestCase):
def test_download_response(self): def test_download_response(self):
"""'default_file' streams Document.file.""" """'default_file' streams Document.file."""
setup_document() setup_document()
url = reverse("object:default_file", kwargs={"slug": slug}) url = reverse('object:default_file', kwargs={'slug': slug})
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content=file_content,
content=file_content, basename=file_name,
basename=file_name, mime_type='text/plain')
mime_type="text/plain",
)
class AnotherFileTestCase(django.test.TestCase): class AnotherFileTestCase(django.test.TestCase):
@ -44,15 +47,13 @@ class AnotherFileTestCase(django.test.TestCase):
def test_download_response(self): def test_download_response(self):
"""'another_file' streams Document.another_file.""" """'another_file' streams Document.another_file."""
setup_document() setup_document()
url = reverse("object:another_file", kwargs={"slug": slug}) url = reverse('object:another_file', kwargs={'slug': slug})
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content=another_content,
content=another_content, basename=another_name,
basename=another_name, mime_type='text/plain')
mime_type="text/plain",
)
class DeserializedBasenameTestCase(django.test.TestCase): class DeserializedBasenameTestCase(django.test.TestCase):
@ -60,28 +61,10 @@ class DeserializedBasenameTestCase(django.test.TestCase):
def test_download_response(self): def test_download_response(self):
"'deserialized_basename' streams Document.file with custom basename." "'deserialized_basename' streams Document.file with custom basename."
setup_document() setup_document()
url = reverse("object:deserialized_basename", kwargs={"slug": slug}) url = reverse('object:deserialized_basename', kwargs={'slug': slug})
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content=file_content,
content=file_content, basename=basename,
basename=basename, mime_type='text/plain')
mime_type="text/plain",
)
class InlineFileTestCase(django.test.TestCase):
@temporary_media_root()
def test_download_response(self):
"'inline_file_view' streams Document.file inline."
setup_document()
url = reverse("object:inline_file", kwargs={"slug": slug})
response = self.client.get(url)
assert_download_response(
self,
response,
content=file_content,
mime_type="text/plain",
attachment=False,
)

View file

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

View file

@ -2,19 +2,17 @@ from django_downloadview import ObjectDownloadView
from demoproject.object.models import Document from demoproject.object.models import Document
#: Serve ``file`` attribute of ``Document`` model. #: Serve ``file`` attribute of ``Document`` model.
default_file_view = ObjectDownloadView.as_view(model=Document) default_file_view = ObjectDownloadView.as_view(model=Document)
#: Serve ``another_file`` attribute of ``Document`` model. #: Serve ``another_file`` attribute of ``Document`` model.
another_file_view = ObjectDownloadView.as_view( another_file_view = ObjectDownloadView.as_view(
model=Document, file_field="another_file" model=Document,
) file_field='another_file')
#: Serve ``file`` attribute of ``Document`` model, using client-side filename #: Serve ``file`` attribute of ``Document`` model, using client-side filename
#: from model. #: from model.
deserialized_basename_view = ObjectDownloadView.as_view( deserialized_basename_view = ObjectDownloadView.as_view(
model=Document, basename_field="basename" model=Document,
) basename_field='basename')
#: Serve ``file`` attribute of ``Document`` model, inline (not as attachment).
inline_file_view = ObjectDownloadView.as_view(model=Document, attachment=False)

View file

@ -1,5 +1,5 @@
from django.core.urlresolvers import reverse
import django.test import django.test
from django.urls import reverse
from django_downloadview import assert_download_response from django_downloadview import assert_download_response
@ -7,26 +7,22 @@ from django_downloadview import assert_download_response
class StaticPathTestCase(django.test.TestCase): class StaticPathTestCase(django.test.TestCase):
def test_download_response(self): def test_download_response(self):
"""'static_path' serves 'fixtures/hello-world.txt'.""" """'static_path' serves 'fixtures/hello-world.txt'."""
url = reverse("path:static_path") url = reverse('path:static_path')
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content='Hello world!\n',
content="Hello world!\n", basename='hello-world.txt',
basename="hello-world.txt", mime_type='text/plain')
mime_type="text/plain",
)
class DynamicPathTestCase(django.test.TestCase): class DynamicPathTestCase(django.test.TestCase):
def test_download_response(self): def test_download_response(self):
"""'dynamic_path' serves 'fixtures/{path}'.""" """'dynamic_path' serves 'fixtures/{path}'."""
url = reverse("path:dynamic_path", kwargs={"path": "hello-world.txt"}) url = reverse('path:dynamic_path', kwargs={'path': 'hello-world.txt'})
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content='Hello world!\n',
content="Hello world!\n", basename='hello-world.txt',
basename="hello-world.txt", mime_type='text/plain')
mime_type="text/plain",
)

View file

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

View file

@ -2,12 +2,13 @@ import os
from django_downloadview import PathDownloadView from django_downloadview import PathDownloadView
# Let's initialize some fixtures. # Let's initialize some fixtures.
app_dir = os.path.dirname(os.path.abspath(__file__)) app_dir = os.path.dirname(os.path.abspath(__file__))
project_dir = os.path.dirname(app_dir) project_dir = os.path.dirname(app_dir)
fixtures_dir = os.path.join(project_dir, "fixtures") fixtures_dir = os.path.join(project_dir, 'fixtures')
#: Path to a text file that says 'Hello world!'. #: Path to a text file that says 'Hello world!'.
hello_world_path = os.path.join(fixtures_dir, "hello-world.txt") hello_world_path = os.path.join(fixtures_dir, 'hello-world.txt')
#: Serve ``fixtures/hello-world.txt`` file. #: Serve ``fixtures/hello-world.txt`` file.
static_path = PathDownloadView.as_view(path=hello_world_path) static_path = PathDownloadView.as_view(path=hello_world_path)
@ -26,11 +27,10 @@ class DynamicPathDownloadView(PathDownloadView):
:class:`StorageDownloadView` :class:`StorageDownloadView`
""" """
def get_path(self): def get_path(self):
"""Return path inside fixtures directory.""" """Return path inside fixtures directory."""
# Get path from URL resolvers or as_view kwarg. # Get path from URL resolvers or as_view kwarg.
relative_path = super().get_path() relative_path = super(DynamicPathDownloadView, self).get_path()
# Make it absolute. # Make it absolute.
absolute_path = os.path.join(fixtures_dir, relative_path) absolute_path = os.path.join(fixtures_dir, relative_path)
return absolute_path return absolute_path

149
demo/demoproject/settings.py Normal file → Executable file
View file

@ -1,26 +1,26 @@
"""Django settings for django-downloadview demo project.""" # -*- coding: utf-8 -*-
"""Django settings for Django-DownloadView demo project."""
import os from os.path import abspath, dirname, join
# Configure some relative directories. # Configure some relative directories.
demoproject_dir = os.path.dirname(os.path.abspath(__file__)) demoproject_dir = dirname(abspath(__file__))
demo_dir = os.path.dirname(demoproject_dir) demo_dir = dirname(demoproject_dir)
root_dir = os.path.dirname(demo_dir) root_dir = dirname(demo_dir)
data_dir = os.path.join(root_dir, "var") data_dir = join(root_dir, 'var')
cfg_dir = os.path.join(root_dir, "etc") cfg_dir = join(root_dir, 'etc')
# Mandatory settings. # Mandatory settings.
ROOT_URLCONF = "demoproject.urls" ROOT_URLCONF = 'demoproject.urls'
WSGI_APPLICATION = "demoproject.wsgi.application" WSGI_APPLICATION = 'demoproject.wsgi.application'
# Database. # Database.
DATABASES = { DATABASES = {
"default": { 'default': {
"ENGINE": "django.db.backends.sqlite3", 'ENGINE': 'django.db.backends.sqlite3',
"NAME": os.path.join(data_dir, "db.sqlite"), 'NAME': join(data_dir, 'db.sqlite'),
} }
} }
@ -29,106 +29,59 @@ DATABASES = {
SECRET_KEY = "This is a secret made public on project's repository." SECRET_KEY = "This is a secret made public on project's repository."
# Media and static files. # Media and static files.
MEDIA_ROOT = os.path.join(data_dir, "media") MEDIA_ROOT = join(data_dir, 'media')
MEDIA_URL = "/media/" MEDIA_URL = '/media/'
STATIC_ROOT = os.path.join(data_dir, "static") STATIC_ROOT = join(data_dir, 'static')
STATIC_URL = "/static/" STATIC_URL = '/static/'
# Applications. # Applications.
INSTALLED_APPS = ( INSTALLED_APPS = (
# The actual django-downloadview demo.
"demoproject",
"demoproject.object", # Demo around ObjectDownloadView
"demoproject.storage", # Demo around StorageDownloadView
"demoproject.path", # Demo around PathDownloadView
"demoproject.http", # Demo around HTTPDownloadView
"demoproject.virtual", # Demo around VirtualDownloadView
"demoproject.nginx", # Sample optimizations for Nginx X-Accel.
"demoproject.apache", # Sample optimizations for Apache X-Sendfile.
"demoproject.lighttpd", # Sample optimizations for Lighttpd X-Sendfile.
# Standard Django applications. # Standard Django applications.
"django.contrib.auth", 'django.contrib.auth',
"django.contrib.contenttypes", 'django.contrib.contenttypes',
"django.contrib.sessions", 'django.contrib.sessions',
"django.contrib.sites", 'django.contrib.sites',
"django.contrib.messages", 'django.contrib.messages',
"django.contrib.staticfiles", 'django.contrib.staticfiles',
# The actual django-downloadview demo.
'demoproject',
'demoproject.object', # Demo around ObjectDownloadView
'demoproject.storage', # Demo around StorageDownloadView
'demoproject.path', # Demo around PathDownloadView
'demoproject.http', # Demo around HTTPDownloadView
'demoproject.virtual', # Demo around VirtualDownloadView
'demoproject.nginx', # Sample optimizations for Nginx X-Accel.
# For test purposes. The demo project is part of django-downloadview
# test suite.
'django_nose',
) )
# BEGIN middlewares # Middlewares.
MIDDLEWARE = [ MIDDLEWARE_CLASSES = [
"django.middleware.common.CommonMiddleware", 'django.middleware.common.CommonMiddleware',
"django.contrib.sessions.middleware.SessionMiddleware", 'django.contrib.sessions.middleware.SessionMiddleware',
"django.middleware.csrf.CsrfViewMiddleware", 'django.middleware.csrf.CsrfViewMiddleware',
"django.contrib.auth.middleware.AuthenticationMiddleware", 'django.contrib.auth.middleware.AuthenticationMiddleware',
"django.contrib.messages.middleware.MessageMiddleware", 'django.contrib.messages.middleware.MessageMiddleware',
"django_downloadview.SmartDownloadMiddleware", 'django_downloadview.SmartDownloadMiddleware'
] ]
# END middlewares
# Specific configuration for django_downloadview.SmartDownloadMiddleware. # Specific configuration for django_downloadview.SmartDownloadMiddleware.
# BEGIN backend DOWNLOADVIEW_BACKEND = 'django_downloadview.nginx.XAccelRedirectMiddleware'
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 = [ DOWNLOADVIEW_RULES = [
{ {'source_url': '/media/nginx/',
"source_url": "/media/nginx/", 'destination_url': '/nginx-optimized-by-middleware/'},
"destination_url": "/nginx-optimized-by-middleware/",
},
]
# END rules
DOWNLOADVIEW_RULES += [
{
"source_url": "/media/apache/",
"destination_dir": "/apache-optimized-by-middleware/",
# Bypass global default backend with additional argument "backend".
# Notice that in general use case, ``DOWNLOADVIEW_BACKEND`` should be
# enough. Here, the django_downloadview demo project needs to
# demonstrate usage of several backends.
"backend": "django_downloadview.apache.XSendfileMiddleware",
},
{
"source_url": "/media/lighttpd/",
"destination_dir": "/lighttpd-optimized-by-middleware/",
# Bypass global default backend with additional argument "backend".
# Notice that in general use case, ``DOWNLOADVIEW_BACKEND`` should be
# enough. Here, the django_downloadview demo project needs to
# demonstrate usage of several backends.
"backend": "django_downloadview.lighttpd.XSendfileMiddleware",
},
] ]
# Test/development settings. # Test/development settings.
DEBUG = True DEBUG = True
TEMPLATE_DEBUG = DEBUG
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
TEMPLATES = [ nose_cfg_dir = join(cfg_dir, 'nose')
{ NOSE_ARGS = ['--config={etc}/base.cfg'.format(etc=nose_cfg_dir),
"BACKEND": "django.template.backends.django.DjangoTemplates", '--config={etc}/{package}.cfg'.format(etc=nose_cfg_dir,
"DIRS": [os.path.join(os.path.dirname(__file__), "templates")], package=__package__)]
"OPTIONS": {
"debug": DEBUG,
"context_processors": [
# Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this
# list if you haven't customized them:
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
],
},
},
]

View file

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

View file

@ -1,21 +1,17 @@
import datetime
import unittest import unittest
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.http.response import HttpResponseNotModified from django.core.urlresolvers import reverse
import django.test import django.test
from django.urls import reverse
from django_downloadview import ( from django_downloadview import assert_download_response, temporary_media_root
assert_download_response, from django_downloadview import setup_view
setup_view,
temporary_media_root,
)
from demoproject.storage import views from demoproject.storage import views
# Fixtures. # Fixtures.
file_content = "Hello world!\n" file_content = 'Hello world!\n'
def setup_file(path): def setup_file(path):
@ -26,48 +22,18 @@ class StaticPathTestCase(django.test.TestCase):
@temporary_media_root() @temporary_media_root()
def test_download_response(self): def test_download_response(self):
"""'storage:static_path' streams file by path.""" """'storage:static_path' streams file by path."""
setup_file("1.txt") setup_file('1.txt')
url = reverse("storage:static_path", kwargs={"path": "1.txt"}) url = reverse('storage:static_path', kwargs={'path': '1.txt'})
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content=file_content,
content=file_content, basename='1.txt',
basename="1.txt", mime_type='text/plain')
mime_type="text/plain",
)
@temporary_media_root()
def test_not_modified_download_response(self):
"""'storage:static_path' sends not modified response if unmodified."""
setup_file("1.txt")
url = reverse("storage:static_path", kwargs={"path": "1.txt"})
year = datetime.date.today().year + 4
response = self.client.get(
url, headers={"if-modified-since": f"Sat, 29 Oct {year} 19:43:31 GMT"}
)
self.assertTrue(isinstance(response, HttpResponseNotModified))
@temporary_media_root()
def test_modified_since_download_response(self):
"""'storage:static_path' streams file if modified."""
setup_file("1.txt")
url = reverse("storage:static_path", kwargs={"path": "1.txt"})
response = self.client.get(
url, headers={"if-modified-since": "Sat, 29 Oct 1980 19:43:31 GMT"}
)
assert_download_response(
self,
response,
content=file_content,
basename="1.txt",
mime_type="text/plain",
)
class DynamicPathIntegrationTestCase(django.test.TestCase): class DynamicPathIntegrationTestCase(django.test.TestCase):
"""Integration tests around ``storage:dynamic_path`` URL.""" """Integration tests around ``storage:dynamic_path`` URL."""
@temporary_media_root() @temporary_media_root()
def test_download_response(self): def test_download_response(self):
"""'dynamic_path' streams file by generated path. """'dynamic_path' streams file by generated path.
@ -80,21 +46,18 @@ class DynamicPathIntegrationTestCase(django.test.TestCase):
file in storage. file in storage.
""" """
setup_file("1.TXT") setup_file('1.TXT')
url = reverse("storage:dynamic_path", kwargs={"path": "1.txt"}) url = reverse('storage:dynamic_path', kwargs={'path': '1.txt'})
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content=file_content,
content=file_content, basename='1.TXT',
basename="1.TXT", mime_type='text/plain')
mime_type="text/plain",
)
class DynamicPathUnitTestCase(unittest.TestCase): class DynamicPathUnitTestCase(unittest.TestCase):
"""Unit tests around ``views.DynamicStorageDownloadView``.""" """Unit tests around ``views.DynamicStorageDownloadView``."""
def test_get_path(self): def test_get_path(self):
"""DynamicStorageDownloadView.get_path() returns uppercase path. """DynamicStorageDownloadView.get_path() returns uppercase path.
@ -106,10 +69,8 @@ class DynamicPathUnitTestCase(unittest.TestCase):
URL works. It targets only custom ``DynamicStorageDownloadView`` class. URL works. It targets only custom ``DynamicStorageDownloadView`` class.
""" """
view = setup_view( view = setup_view(views.DynamicStorageDownloadView(),
views.DynamicStorageDownloadView(), django.test.RequestFactory().get('/fake-url'),
django.test.RequestFactory().get("/fake-url"), path='dummy path')
path="dummy path",
)
path = view.get_path() path = view.get_path()
self.assertEqual(path, "DUMMY PATH") self.assertEqual(path, 'DUMMY PATH')

View file

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

View file

@ -2,6 +2,7 @@ from django.core.files.storage import FileSystemStorage
from django_downloadview import StorageDownloadView from django_downloadview import StorageDownloadView
storage = FileSystemStorage() storage = FileSystemStorage()
@ -11,10 +12,9 @@ static_path = StorageDownloadView.as_view(storage=storage)
class DynamicStorageDownloadView(StorageDownloadView): class DynamicStorageDownloadView(StorageDownloadView):
"""Serve file of storage by path.upper().""" """Serve file of storage by path.upper()."""
def get_path(self): def get_path(self):
"""Return uppercase path.""" """Return uppercase path."""
return super().get_path().upper() return super(DynamicStorageDownloadView, self).get_path().upper()
dynamic_path = DynamicStorageDownloadView.as_view(storage=storage) dynamic_path = DynamicStorageDownloadView.as_view(storage=storage)

View file

@ -1,3 +1,4 @@
{% load url from future %}
<html> <html>
<head> <head>
<title>django-downloadview demo</title> <title>django-downloadview demo</title>
@ -10,9 +11,6 @@
<p>In the following views, Django streams the files, no optimization <p>In the following views, Django streams the files, no optimization
has been setup.</p> has been setup.</p>
<ul> <ul>
<li>
<a href="{% url 'object:default_file' 'hello-world' %}">object:default_file</a>
</li>
</ul> </ul>
<h2>Optimized downloads</h2> <h2>Optimized downloads</h2>

View file

@ -1,14 +1,13 @@
# coding=utf8
"""Test suite for demoproject.download.""" """Test suite for demoproject.download."""
from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
class HomeViewTestCase(TestCase): class HomeViewTestCase(TestCase):
"""Test homepage.""" """Test homepage."""
def test_get(self): def test_get(self):
"""Homepage returns HTTP 200.""" """Homepage returns HTTP 200."""
home_url = reverse("home") home_url = reverse('home')
response = self.client.get(home_url) response = self.client.get(home_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

58
demo/demoproject/urls.py Normal file → Executable file
View file

@ -1,44 +1,36 @@
from django.urls import include, path from django.conf.urls import patterns, include, url
from django.views.generic import TemplateView from django.views.generic import TemplateView
home = TemplateView.as_view(template_name="home.html")
home = TemplateView.as_view(template_name='home.html')
urlpatterns = [ urlpatterns = patterns(
'',
# ObjectDownloadView. # ObjectDownloadView.
path( url(r'^object/', include('demoproject.object.urls',
"object/", app_name='object',
include("demoproject.object.urls", namespace="object"), namespace='object')),
),
# StorageDownloadView. # StorageDownloadView.
path( url(r'^storage/', include('demoproject.storage.urls',
"storage/", app_name='storage',
include("demoproject.storage.urls", namespace="storage"), namespace='storage')),
),
# PathDownloadView. # PathDownloadView.
path("path/", include("demoproject.path.urls", namespace="path")), url(r'^path/', include('demoproject.path.urls',
app_name='path',
namespace='path')),
# HTTPDownloadView. # HTTPDownloadView.
path("http/", include("demoproject.http.urls", namespace="http")), url(r'^http/', include('demoproject.http.urls',
app_name='http',
namespace='http')),
# VirtualDownloadView. # VirtualDownloadView.
path( url(r'^virtual/', include('demoproject.virtual.urls',
"virtual/", app_name='virtual',
include("demoproject.virtual.urls", namespace="virtual"), namespace='virtual')),
),
# Nginx optimizations. # Nginx optimizations.
path( url(r'^nginx/', include('demoproject.nginx.urls',
"nginx/", app_name='nginx',
include("demoproject.nginx.urls", namespace="nginx"), namespace='nginx')),
),
# Apache optimizations.
path(
"apache/",
include("demoproject.apache.urls", namespace="apache"),
),
# Lighttpd optimizations.
path(
"lighttpd/",
include("demoproject.lighttpd.urls", namespace="lighttpd"),
),
# An informative homepage. # An informative homepage.
path("", home, name="home"), url(r'', home, name='home')
] )

View file

@ -1,5 +1,5 @@
from django.core.urlresolvers import reverse
import django.test import django.test
from django.urls import reverse
from django_downloadview import assert_download_response from django_downloadview import assert_download_response
@ -7,40 +7,34 @@ from django_downloadview import assert_download_response
class TextTestCase(django.test.TestCase): class TextTestCase(django.test.TestCase):
def test_download_response(self): def test_download_response(self):
"""'virtual:text' serves 'hello-world.txt' from unicode.""" """'virtual:text' serves 'hello-world.txt' from unicode."""
url = reverse("virtual:text") url = reverse('virtual:text')
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content='Hello world!\n',
content="Hello world!\n", basename='hello-world.txt',
basename="hello-world.txt", mime_type='text/plain')
mime_type="text/plain",
)
class StringIOTestCase(django.test.TestCase): class StringIOTestCase(django.test.TestCase):
def test_download_response(self): def test_download_response(self):
"""'virtual:stringio' serves 'hello-world.txt' from stringio.""" """'virtual:stringio' serves 'hello-world.txt' from stringio."""
url = reverse("virtual:stringio") url = reverse('virtual:stringio')
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content='Hello world!\n',
content="Hello world!\n", basename='hello-world.txt',
basename="hello-world.txt", mime_type='text/plain')
mime_type="text/plain",
)
class GeneratedTestCase(django.test.TestCase): class GeneratedTestCase(django.test.TestCase):
def test_download_response(self): def test_download_response(self):
"""'virtual:generated' serves 'hello-world.txt' from generator.""" """'virtual:generated' serves 'hello-world.txt' from generator."""
url = reverse("virtual:generated") url = reverse('virtual:generated')
response = self.client.get(url) response = self.client.get(url)
assert_download_response( assert_download_response(self,
self, response,
response, content='Hello world!\n',
content="Hello world!\n", basename='hello-world.txt',
basename="hello-world.txt", mime_type='text/plain')
mime_type="text/plain",
)

View file

@ -1,10 +1,17 @@
from django.urls import path from django.conf.urls import patterns, url
from demoproject.virtual import views from demoproject.virtual import views
app_name = "virtual"
urlpatterns = [ urlpatterns = patterns(
path("text/", views.TextDownloadView.as_view(), name="text"), '',
path("stringio/", views.StringIODownloadView.as_view(), name="stringio"), url(r'^text/$',
path("gerenated/", views.GeneratedDownloadView.as_view(), name="generated"), views.TextDownloadView.as_view(),
] name='text'),
url(r'^stringio/$',
views.StringIODownloadView.as_view(),
name='stringio'),
url(r'^gerenated/$',
views.GeneratedDownloadView.as_view(),
name='generated'),
)

View file

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

View file

@ -12,16 +12,14 @@ middleware here, or combine a Django application with an application of another
framework. framework.
""" """
import os import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "%s.settings" % __package__) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "%s.settings" % __package__)
# This application object is used by any WSGI server configured to use this # This application object is used by any WSGI server configured to use this
# file. This includes Django's development server, if the WSGI_APPLICATION # file. This includes Django's development server, if the WSGI_APPLICATION
# setting points here. # setting points here.
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application() application = get_wsgi_application()
# Apply WSGI middleware here. # Apply WSGI middleware here.

View file

@ -1,26 +1,47 @@
# -*- coding: utf-8 -*-
"""Python packaging."""
import os import os
from setuptools import setup from setuptools import setup
here = os.path.abspath(os.path.dirname(__file__))
setup( here = os.path.abspath(os.path.dirname(__file__))
name="django-downloadview-demo", project_root = os.path.dirname(here)
version="1.0",
description="Serve files with Django and reverse-proxies.",
long_description=open(os.path.join(here, "README.rst")).read(), NAME = 'django-downloadview-demo'
classifiers=[ DESCRIPTION = 'Serve files with Django and reverse-proxies.'
"Development Status :: 5 - Production/Stable", README = open(os.path.join(here, 'README.rst')).read()
"License :: OSI Approved :: BSD License", VERSION = open(os.path.join(project_root, 'VERSION')).read().strip()
"Programming Language :: Python :: 3", AUTHOR = u'Benoît Bryon'
"Framework :: Django", EMAIL = u'benoit@marmelune.net'
], URL = 'https://{name}.readthedocs.org/'.format(name=NAME)
author="Benoît Bryon", CLASSIFIERS = ['Development Status :: 4 - Beta',
author_email="benoit@marmelune.net", 'License :: OSI Approved :: BSD License',
url="https://django-downloadview.readthedocs.io/", 'Programming Language :: Python :: 2.7',
license="BSD", 'Programming Language :: Python :: 2.6',
packages=["demoproject"], 'Framework :: Django']
include_package_data=True, KEYWORDS = []
zip_safe=False, PACKAGES = ['demoproject']
install_requires=["django-downloadview", "pytest-django"], REQUIREMENTS = ['django-downloadview', 'django-nose']
entry_points={"console_scripts": ["demo = demoproject.manage:main"]}, ENTRY_POINTS = {
) 'console_scripts': ['demo = demoproject.manage:main']
}
if __name__ == '__main__': # Don't run setup() when we import this module.
setup(name=NAME,
version=VERSION,
description=DESCRIPTION,
long_description=README,
classifiers=CLASSIFIERS,
keywords=' '.join(KEYWORDS),
author=AUTHOR,
author_email=EMAIL,
url=URL,
license='BSD',
packages=PACKAGES,
include_package_data=True,
zip_safe=False,
install_requires=REQUIREMENTS,
entry_points=ENTRY_POINTS)

View file

@ -1,8 +1,12 @@
# -*- coding: utf-8 -*-
"""Serve files with Django and reverse proxies.""" """Serve files with Django and reverse proxies."""
import pkg_resources
from django_downloadview.api import * # NoQA
import importlib.metadata
#: Module version, as defined in PEP-0396. #: Module version, as defined in PEP-0396.
__version__ = importlib.metadata.version(__package__.replace("-", "_")) __version__ = pkg_resources.get_distribution(__package__.replace('-', '_')) \
.version
# API shortcuts.
from django_downloadview.api import * # NoQA

View file

@ -1,13 +0,0 @@
"""Optimizations for Apache.
See also `documentation of mod_xsendfile for Apache
<https://tn123.org/mod_xsendfile/>`_ and :doc:`narrative documentation about
Apache optimizations </optimizations/apache>`.
"""
# API shortcuts.
from django_downloadview.apache.decorators import x_sendfile # NoQA
from django_downloadview.apache.middlewares import XSendfileMiddleware # NoQA
from django_downloadview.apache.response import XSendfileResponse # NoQA
from django_downloadview.apache.tests import assert_x_sendfile # NoQA

View file

@ -1,16 +0,0 @@
"""Decorators to apply Apache X-Sendfile on a specific view."""
from django_downloadview.apache.middlewares import XSendfileMiddleware
from django_downloadview.decorators import DownloadDecorator
def x_sendfile(view_func, *args, **kwargs):
"""Apply
:class:`~django_downloadview.apache.middlewares.XSendfileMiddleware` to
``view_func``.
Proxies (``*args``, ``**kwargs``) to middleware constructor.
"""
decorator = DownloadDecorator(XSendfileMiddleware)
return decorator(view_func, *args, **kwargs)

View file

@ -1,36 +0,0 @@
from django_downloadview.apache.response import XSendfileResponse
from django_downloadview.middlewares import (
NoRedirectionMatch,
ProxiedDownloadMiddleware,
)
class XSendfileMiddleware(ProxiedDownloadMiddleware):
"""Configurable middleware, for use in decorators or in global middlewares.
Standard Django middlewares are configured globally via settings. Instances
of this class are to be configured individually. It makes it possible to
use this class as the factory in
:py:class:`django_downloadview.decorators.DownloadDecorator`.
"""
def __init__(
self, get_response=None, source_dir=None, source_url=None, destination_dir=None
):
"""Constructor."""
super().__init__(get_response, source_dir, source_url, destination_dir)
def process_download_response(self, request, response):
"""Replace DownloadResponse instances by XSendfileResponse ones."""
try:
redirect_url = self.get_redirect_url(response)
except NoRedirectionMatch:
return response
return XSendfileResponse(
file_path=redirect_url,
content_type=response["Content-Type"],
basename=response.basename,
attachment=response.attachment,
headers=response.headers,
)

View file

@ -1,22 +0,0 @@
"""Apache's specific responses."""
import os.path
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
class XSendfileResponse(ProxiedDownloadResponse):
"Delegates serving file to Apache via X-Sendfile header."
def __init__(
self, file_path, content_type, basename=None, attachment=True, headers=None
):
"""Return a HttpResponse with headers for Apache X-Sendfile."""
# content-type must be provided only as keyword argument to response
if headers and content_type:
headers.pop("Content-Type", None)
super().__init__(content_type=content_type, headers=headers)
if attachment:
self.basename = basename or os.path.basename(file_path)
self["Content-Disposition"] = content_disposition(self.basename)
self["X-Sendfile"] = file_path

View file

@ -1,62 +0,0 @@
from django_downloadview.apache.response import XSendfileResponse
class XSendfileValidator:
"""Utility class to validate XSendfileResponse instances.
See also :py:func:`assert_x_sendfile` shortcut function.
"""
def __call__(self, test_case, response, **assertions):
"""Assert that ``response`` is a valid X-Sendfile response.
Optional ``assertions`` dictionary can be used to check additional
items:
* ``basename``: the basename of the file in the response.
* ``content_type``: the value of "Content-Type" header.
* ``file_path``: the value of "X-Sendfile" header.
"""
self.assert_x_sendfile_response(test_case, response)
for key, value in assertions.items():
assert_func = getattr(self, "assert_%s" % key)
assert_func(test_case, response, value)
def assert_x_sendfile_response(self, test_case, response):
test_case.assertTrue(isinstance(response, XSendfileResponse))
def assert_basename(self, test_case, response, value):
test_case.assertEqual(response.basename, value)
def assert_content_type(self, test_case, response, value):
test_case.assertEqual(response["Content-Type"], value)
def assert_file_path(self, test_case, response, value):
test_case.assertEqual(response["X-Sendfile"], value)
def assert_attachment(self, test_case, response, value):
header = "Content-Disposition"
if value:
test_case.assertTrue(response[header].startswith("attachment"))
else:
test_case.assertFalse(header in response)
def assert_x_sendfile(test_case, response, **assertions):
"""Make ``test_case`` assert that ``response`` is a XSendfileResponse.
Optional ``assertions`` dictionary can be used to check additional items:
* ``basename``: the basename of the file in the response.
* ``content_type``: the value of "Content-Type" header.
* ``file_path``: the value of "X-Sendfile" header.
"""
validator = XSendfileValidator()
return validator(test_case, response, **assertions)

View file

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

View file

@ -5,12 +5,6 @@ See also decorators provided by server-specific modules, such as
""" """
from functools import wraps
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
class DownloadDecorator(object): class DownloadDecorator(object):
"""View decorator factory to apply middleware to ``view_func``'s response. """View decorator factory to apply middleware to ``view_func``'s response.
@ -23,54 +17,16 @@ class DownloadDecorator(object):
method is applied on response. method is applied on response.
""" """
def __init__(self, middleware_factory): def __init__(self, middleware_factory):
"""Create a download view decorator.""" """Create a download view decorator."""
self.middleware_factory = middleware_factory self.middleware_factory = middleware_factory
def __call__(self, view_func, *middleware_args, **middleware_kwargs): def __call__(self, view_func, *middleware_args, **middleware_kwargs):
"""Return ``view_func`` decorated with response middleware.""" """Return ``view_func`` decorated with response middleware."""
def decorated(request, *view_args, **view_kwargs): def decorated(request, *view_args, **view_kwargs):
"""Return view's response modified by middleware.""" """Return view's response modified by middleware."""
response = view_func(request, *view_args, **view_kwargs) response = view_func(request, *view_args, **view_kwargs)
middleware = self.middleware_factory(*middleware_args, **middleware_kwargs) middleware = self.middleware_factory(*middleware_args,
**middleware_kwargs)
return middleware.process_response(request, response) return middleware.process_response(request, response)
return decorated return decorated
def _signature_is_valid(request):
"""
Validator that raises a PermissionDenied error on invalid and
mismatching signatures.
"""
signer = TimestampSigner()
signature = request.GET.get("X-Signature")
expiration = getattr(settings, "DOWNLOADVIEW_URL_EXPIRATION", None)
try:
signature_path = signer.unsign(signature, max_age=expiration)
except SignatureExpired as e:
raise PermissionDenied("Signature expired") from e
except BadSignature as e:
raise PermissionDenied("Signature invalid") from e
except Exception as e:
raise PermissionDenied("Signature error") from e
if request.path != signature_path:
raise PermissionDenied("Signature mismatch")
def signature_required(function):
"""
Decorator that checks for X-Signature query parameter to authorize access to views.
"""
@wraps(function)
def decorator(request, *args, **kwargs):
_signature_is_valid(request)
return function(request, *args, **kwargs)
return decorator

View file

@ -1,10 +0,0 @@
"""Custom exceptions."""
class FileNotFound(IOError):
"""Requested file does not exist.
This exception is to be raised when operations (such as read) fail because
file does not exist (whatever the storage or location).
"""

View file

@ -1,13 +1,12 @@
# -*- coding: utf-8 -*-
"""File wrappers for use as exchange data between views and responses.""" """File wrappers for use as exchange data between views and responses."""
from __future__ import absolute_import
from io import BytesIO from io import BytesIO
from urllib.parse import urlparse from urlparse import urlparse
from django.core.files.base import File from django.core.files.base import File
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django_downloadview.io import BytesIteratorIO
import requests import requests
@ -18,7 +17,6 @@ class StorageFile(File):
but unrelated to model instance. but unrelated to model instance.
""" """
def __init__(self, storage, name, file=None): def __init__(self, storage, name, file=None):
"""Constructor. """Constructor.
@ -35,8 +33,8 @@ class StorageFile(File):
def _get_file(self): def _get_file(self):
"""Getter for :py:attr:``file`` property.""" """Getter for :py:attr:``file`` property."""
if not hasattr(self, "_file") or self._file is None: if not hasattr(self, '_file') or self._file is None:
self._file = self.storage.open(self.name, "rb") self._file = self.storage.open(self.name, 'rb')
return self._file return self._file
def _set_file(self, file): def _set_file(self, file):
@ -50,7 +48,7 @@ class StorageFile(File):
#: Required by django.core.files.utils.FileProxy. #: Required by django.core.files.utils.FileProxy.
file = property(_get_file, _set_file, _del_file) file = property(_get_file, _set_file, _del_file)
def open(self, mode="rb"): def open(self, mode='rb'):
"""Retrieves the specified file from storage and return open() result. """Retrieves the specified file from storage and return open() result.
Proxy to self.storage.open(self.name, mode). Proxy to self.storage.open(self.name, mode).
@ -122,10 +120,7 @@ class StorageFile(File):
Proxy to self.storage.accessed_time(self.name). Proxy to self.storage.accessed_time(self.name).
""" """
try: return self.storage.accessed(self.name)
return self.storage.get_accessed_time(self.name)
except AttributeError:
return self.storage.accessed_time(self.name)
@property @property
def created_time(self): def created_time(self):
@ -134,10 +129,7 @@ class StorageFile(File):
Proxy to self.storage.created_time(self.name). Proxy to self.storage.created_time(self.name).
""" """
try: return self.storage.created_time(self.name)
return self.storage.get_created_time(self.name)
except AttributeError:
return self.storage.created_time(self.name)
@property @property
def modified_time(self): def modified_time(self):
@ -146,16 +138,12 @@ class StorageFile(File):
Proxy to self.storage.modified_time(self.name). Proxy to self.storage.modified_time(self.name).
""" """
try: return self.storage.modified_time(self.name)
return self.storage.get_modified_time(self.name)
except AttributeError:
return self.storage.modified_time(self.name)
class VirtualFile(File): class VirtualFile(File):
"""Wrapper for files that live in memory.""" """Wrapper for files that live in memory."""
def __init__(self, file=None, name=u'', url='', size=None):
def __init__(self, file=None, name="", url="", size=None):
"""Constructor. """Constructor.
file: file:
@ -168,7 +156,7 @@ class VirtualFile(File):
File URL. File URL.
""" """
super().__init__(file, name) super(VirtualFile, self).__init__(file, name)
self.url = url self.url = url
if size is not None: if size is not None:
self._size = size self._size = size
@ -184,7 +172,7 @@ class VirtualFile(File):
return self._size return self._size
def _set_size(self, value): def _set_size(self, value):
return super()._set_size(value) return super(VirtualFile, self)._set_size(value)
size = property(_get_size, _set_size) size = property(_get_size, _set_size)
@ -206,7 +194,7 @@ class VirtualFile(File):
# If this is the end of a line, yield # If this is the end of a line, yield
# otherwise, wait for the next round # otherwise, wait for the next round
if line[-1] in ("\n", "\r"): if line[-1] in ('\n', '\r'):
yield line yield line
else: else:
buffer_ = line buffer_ = line
@ -215,6 +203,7 @@ class VirtualFile(File):
yield buffer_ yield buffer_
class HTTPFile(File): class HTTPFile(File):
"""Wrapper for files that live on remote HTTP servers. """Wrapper for files that live on remote HTTP servers.
@ -225,19 +214,19 @@ class HTTPFile(File):
Always sets "stream=True" in requests kwargs. Always sets "stream=True" in requests kwargs.
""" """
def __init__(self, request_factory=requests.get, url='', name=u'',
def __init__(self, request_factory=requests.get, url="", name="", **kwargs): **kwargs):
self.request_factory = request_factory self.request_factory = request_factory
self.url = url self.url = url
if name is None: if name is None:
parts = urlparse(url) parts = urlparse(url)
if parts.path: # Name from path. if parts.path: # Name from path.
self.name = parts.path.strip("/").rsplit("/", 1)[-1] self.name = parts.path.strip('/').rsplit('/', 1)[-1]
else: # Name from domain. else: # Name from domain.
self.name = parts.netloc self.name = parts.netloc
else: else:
self.name = name self.name = name
kwargs["stream"] = True kwargs['stream'] = True
self.request_kwargs = kwargs self.request_kwargs = kwargs
@property @property
@ -245,17 +234,13 @@ class HTTPFile(File):
try: try:
return self._request return self._request
except AttributeError: except AttributeError:
self._request = self.request_factory(self.url, **self.request_kwargs) self._request = self.request_factory(self.url,
**self.request_kwargs)
return self._request return self._request
@property @property
def file(self): def file(self):
try: return self.request.raw
return self._file
except AttributeError:
content = self.request.iter_content(decode_unicode=False)
self._file = BytesIteratorIO(content)
return self._file
@property @property
def size(self): def size(self):
@ -264,9 +249,4 @@ class HTTPFile(File):
Reads response's "content-length" header. Reads response's "content-length" header.
""" """
return self.request.headers["Content-Length"] return self.request.headers['Content-Length']
@property
def content_type(self):
"""Return content type of the file (from original response)."""
return self.request.headers["Content-Type"]

View file

@ -1,26 +1,21 @@
# -*- coding: utf-8 -*-
"""Low-level IO operations, for use with file wrappers.""" """Low-level IO operations, for use with file wrappers."""
from __future__ import absolute_import
import io import io
from django.utils.encoding import force_bytes, force_str
class StringIteratorIO(io.TextIOBase):
class TextIteratorIO(io.TextIOBase): """A dynamically generated StringIO-like object.
"""A dynamically generated TextIO-like object.
Original code by Matt Joiner <anacrolix@gmail.com> from: Original code by Matt Joiner <anacrolix@gmail.com> from:
* http://stackoverflow.com/questions/12593576/ * http://stackoverflow.com/questions/12593576/adapt-an-iterator-to-behave-like-a-file-like-object-in-python
* https://gist.github.com/anacrolix/3788413 * https://gist.github.com/anacrolix/3788413
""" """
def __init__(self, iterator): def __init__(self, iterator):
#: Iterator/generator for content.
self._iter = iterator self._iter = iterator
self._left = ''
#: Internal buffer.
self._left = ""
def readable(self): def readable(self):
return True return True
@ -31,113 +26,40 @@ class TextIteratorIO(io.TextIOBase):
self._left = next(self._iter) self._left = next(self._iter)
except StopIteration: except StopIteration:
break break
else:
# Make sure we handle text.
self._left = force_str(self._left)
ret = self._left[:n] ret = self._left[:n]
self._left = self._left[len(ret) :] self._left = self._left[len(ret):]
return ret return ret
def read(self, n=None): def read(self, n=None):
"""Return content up to ``n`` length.""" l = []
chunks = []
if n is None or n < 0: if n is None or n < 0:
while True: while True:
m = self._read1() m = self._read1()
if not m: if not m:
break break
chunks.append(m) l.append(m)
else: else:
while n > 0: while n > 0:
m = self._read1(n) m = self._read1(n)
if not m: if not m:
break break
n -= len(m) n -= len(m)
chunks.append(m) l.append(m)
return "".join(chunks) return ''.join(l)
def readline(self): def readline(self):
chunks = [] l = []
while True: while True:
i = self._left.find("\n") i = self._left.find('\n')
if i == -1: if i == -1:
chunks.append(self._left) l.append(self._left)
try: try:
self._left = next(self._iter) self._left = next(self._iter)
except StopIteration: except StopIteration:
self._left = "" self._left = ''
break break
else: else:
chunks.append(self._left[: i + 1]) l.append(self._left[:i + 1])
self._left = self._left[i + 1 :] self._left = self._left[i + 1:]
break break
return "".join(chunks) return ''.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."""
chunks = []
if n is None or n < 0:
while True:
m = self._read1()
if not m:
break
chunks.append(m)
else:
while n > 0:
m = self._read1(n)
if not m:
break
n -= len(m)
chunks.append(m)
return b"".join(chunks)
def readline(self):
chunks = []
while True:
i = self._left.find(b"\n")
if i == -1:
chunks.append(self._left)
try:
self._left = next(self._iter)
except StopIteration:
self._left = b""
break
else:
chunks.append(self._left[: i + 1])
self._left = self._left[i + 1 :]
break
return b"".join(chunks)

View file

@ -1,14 +0,0 @@
"""Optimizations for Lighttpd.
See also `documentation of X-Sendfile for Lighttpd
<http://redmine.lighttpd.net/projects/lighttpd/wiki/X-LIGHTTPD-send-file>`_ and
:doc:`narrative documentation about Lighttpd optimizations
</optimizations/lighttpd>`.
"""
# API shortcuts.
from django_downloadview.lighttpd.decorators import x_sendfile # NoQA
from django_downloadview.lighttpd.middlewares import XSendfileMiddleware # NoQA
from django_downloadview.lighttpd.response import XSendfileResponse # NoQA
from django_downloadview.lighttpd.tests import assert_x_sendfile # NoQA

View file

@ -1,16 +0,0 @@
"""Decorators to apply Lighttpd X-Sendfile on a specific view."""
from django_downloadview.decorators import DownloadDecorator
from django_downloadview.lighttpd.middlewares import XSendfileMiddleware
def x_sendfile(view_func, *args, **kwargs):
"""Apply
:class:`~django_downloadview.lighttpd.middlewares.XSendfileMiddleware` to
``view_func``.
Proxies (``*args``, ``**kwargs``) to middleware constructor.
"""
decorator = DownloadDecorator(XSendfileMiddleware)
return decorator(view_func, *args, **kwargs)

View file

@ -1,36 +0,0 @@
from django_downloadview.lighttpd.response import XSendfileResponse
from django_downloadview.middlewares import (
NoRedirectionMatch,
ProxiedDownloadMiddleware,
)
class XSendfileMiddleware(ProxiedDownloadMiddleware):
"""Configurable middleware, for use in decorators or in global middlewares.
Standard Django middlewares are configured globally via settings. Instances
of this class are to be configured individually. It makes it possible to
use this class as the factory in
:py:class:`django_downloadview.decorators.DownloadDecorator`.
"""
def __init__(
self, get_response=None, source_dir=None, source_url=None, destination_dir=None
):
"""Constructor."""
super().__init__(get_response, source_dir, source_url, destination_dir)
def process_download_response(self, request, response):
"""Replace DownloadResponse instances by XSendfileResponse ones."""
try:
redirect_url = self.get_redirect_url(response)
except NoRedirectionMatch:
return response
return XSendfileResponse(
file_path=redirect_url,
content_type=response["Content-Type"],
basename=response.basename,
attachment=response.attachment,
headers=response.headers,
)

View file

@ -1,22 +0,0 @@
"""Lighttpd's specific responses."""
import os.path
from django_downloadview.response import ProxiedDownloadResponse, content_disposition
class XSendfileResponse(ProxiedDownloadResponse):
"Delegates serving file to Lighttpd via X-Sendfile header."
def __init__(
self, file_path, content_type, basename=None, attachment=True, headers=None
):
"""Return a HttpResponse with headers for Lighttpd X-Sendfile."""
# content-type must be porvided only as keyword argument to response
if headers and content_type:
headers.pop("Content-Type", None)
super().__init__(content_type=content_type, headers=headers)
if attachment:
self.basename = basename or os.path.basename(file_path)
self["Content-Disposition"] = content_disposition(self.basename)
self["X-Sendfile"] = file_path

View file

@ -1,29 +0,0 @@
import django_downloadview.apache.tests
from django_downloadview.lighttpd.response import XSendfileResponse
class XSendfileValidator(django_downloadview.apache.tests.XSendfileValidator):
"""Utility class to validate XSendfileResponse instances.
See also :py:func:`assert_x_sendfile` shortcut function.
"""
def assert_x_sendfile_response(self, test_case, response):
test_case.assertTrue(isinstance(response, XSendfileResponse))
def assert_x_sendfile(test_case, response, **assertions):
"""Make ``test_case`` assert that ``response`` is a XSendfileResponse.
Optional ``assertions`` dictionary can be used to check additional items:
* ``basename``: the basename of the file in the response.
* ``content_type``: the value of "Content-Type" header.
* ``file_path``: the value of "X-Sendfile" header.
"""
validator = XSendfileValidator()
return validator(test_case, response, **assertions)

View file

@ -1,12 +1,11 @@
# -*- coding: utf-8 -*-
"""Base material for download middlewares. """Base material for download middlewares.
Download middlewares capture :py:class:`django_downloadview.DownloadResponse` Download middlewares capture :py:class:`django_downloadview.DownloadResponse`
responses and may replace them with optimized download responses. responses and may replace them with optimized download responses.
""" """
import collections
import collections.abc
import copy
import os import os
from django.conf import settings from django.conf import settings
@ -31,20 +30,12 @@ def is_download_response(response):
return isinstance(response, DownloadResponse) return isinstance(response, DownloadResponse)
class BaseDownloadMiddleware: class BaseDownloadMiddleware(object):
"""Base (abstract) Django middleware that handles download responses. """Base (abstract) Django middleware that handles download responses.
Subclasses **must** implement :py:meth:`process_download_response` method. Subclasses **must** implement :py:meth:`process_download_response` method.
""" """
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
return self.process_response(request, response)
def is_download_response(self, response): def is_download_response(self, response):
"""Return True if ``response`` can be considered as a file download. """Return True if ``response`` can be considered as a file download.
@ -68,7 +59,6 @@ class BaseDownloadMiddleware:
class RealDownloadMiddleware(BaseDownloadMiddleware): class RealDownloadMiddleware(BaseDownloadMiddleware):
"""Download middleware that cannot handle virtual files.""" """Download middleware that cannot handle virtual files."""
def is_download_response(self, response): def is_download_response(self, response):
"""Return True for DownloadResponse, except for "virtual" files. """Return True for DownloadResponse, except for "virtual" files.
@ -77,12 +67,18 @@ class RealDownloadMiddleware(BaseDownloadMiddleware):
whose file attribute have either an URL or a file name. whose file attribute have either an URL or a file name.
""" """
return super().is_download_response(response) and bool( if super(RealDownloadMiddleware, self).is_download_response(response):
getattr(response.file, "url", None) or getattr(response.file, "name", None) try:
) return response.file.url or response.file.name
except AttributeError:
return False
else:
return True
return False
class DownloadDispatcher: class DownloadDispatcherMiddleware(BaseDownloadMiddleware):
"Download middleware that dispatches job to several middleware instances."
def __init__(self, middlewares=AUTO_CONFIGURE): def __init__(self, middlewares=AUTO_CONFIGURE):
#: List of children middlewares. #: List of children middlewares.
self.middlewares = middlewares self.middlewares = middlewares
@ -92,48 +88,35 @@ class DownloadDispatcher:
def auto_configure_middlewares(self): def auto_configure_middlewares(self):
"""Populate :attr:`middlewares` from """Populate :attr:`middlewares` from
``settings.DOWNLOADVIEW_MIDDLEWARES``.""" ``settings.DOWNLOADVIEW_MIDDLEWARES``."""
for key, import_string, kwargs in getattr( for (key, import_string, kwargs) in getattr(settings,
settings, "DOWNLOADVIEW_MIDDLEWARES", [] 'DOWNLOADVIEW_MIDDLEWARES',
): []):
factory = import_member(import_string) factory = import_member(import_string)
middleware = factory(**kwargs) middleware = factory(**kwargs)
self.middlewares.append((key, middleware)) self.middlewares.append((key, middleware))
def dispatch(self, request, response): def process_download_response(self, request, response):
"""Dispatches job to children middlewares.""" """Dispatches job to children middlewares."""
for key, middleware in self.middlewares: for (key, middleware) in self.middlewares:
response = middleware.process_response(request, response) response = middleware.process_response(request, response)
return response return response
class DownloadDispatcherMiddleware(BaseDownloadMiddleware): class SmartDownloadMiddleware(BaseDownloadMiddleware):
"Download middleware that dispatches job to several middleware instances."
def __init__(self, get_response, middlewares=AUTO_CONFIGURE):
super().__init__(get_response)
self.dispatcher = DownloadDispatcher(middlewares)
def process_download_response(self, request, response):
return self.dispatcher.dispatch(request, response)
class SmartDownloadMiddleware(DownloadDispatcherMiddleware):
"""Easy to configure download middleware.""" """Easy to configure download middleware."""
def __init__(self,
def __init__( backend_factory=AUTO_CONFIGURE,
self, backend_options=AUTO_CONFIGURE):
get_response,
backend_factory=AUTO_CONFIGURE,
backend_options=AUTO_CONFIGURE,
):
"""Constructor.""" """Constructor."""
super().__init__(get_response, middlewares=[]) #: :class:`DownloadDispatcher` instance that can hold multiple
#: Callable (typically a class) to instantiate backend (typically a #: backend instances.
self.dispatcher = DownloadDispatcherMiddleware(middlewares=[])
#: Callable (typically a class) to instanciate backend (typically a
#: :class:`DownloadMiddleware` subclass). #: :class:`DownloadMiddleware` subclass).
self.backend_factory = backend_factory self.backend_factory = backend_factory
if self.backend_factory is AUTO_CONFIGURE: if self.backend_factory is AUTO_CONFIGURE:
self.auto_configure_backend_factory() self.auto_configure_backend_factory()
#: List of positional or keyword arguments to instantiate backend #: List of positional or keyword arguments to instanciate backend
#: instances. #: instances.
self.backend_options = backend_options self.backend_options = backend_options
if self.backend_options is AUTO_CONFIGURE: if self.backend_options is AUTO_CONFIGURE:
@ -144,34 +127,31 @@ class SmartDownloadMiddleware(DownloadDispatcherMiddleware):
try: try:
self.backend_factory = import_member(settings.DOWNLOADVIEW_BACKEND) self.backend_factory = import_member(settings.DOWNLOADVIEW_BACKEND)
except AttributeError: except AttributeError:
raise ImproperlyConfigured( raise ImproperlyConfigured('SmartDownloadMiddleware requires '
"SmartDownloadMiddleware requires settings.DOWNLOADVIEW_BACKEND" 'settings.DOWNLOADVIEW_BACKEND')
)
def auto_configure_backend_options(self): def auto_configure_backend_options(self):
"""Populate :attr:`dispatcher` using :attr:`factory` and """Populate :attr:`dispatcher` using :attr:`factory` and
``settings.DOWNLOADVIEW_RULES``.""" ``settings.DOWNLOADVIEW_RULES``."""
try: try:
options_list = copy.deepcopy(settings.DOWNLOADVIEW_RULES) options_list = settings.DOWNLOADVIEW_RULES
except AttributeError: except AttributeError:
raise ImproperlyConfigured( raise ImproperlyConfigured('SmartDownloadMiddleware requires '
"SmartDownloadMiddleware requires settings.DOWNLOADVIEW_RULES" 'settings.DOWNLOADVIEW_RULES')
)
for key, options in enumerate(options_list): for key, options in enumerate(options_list):
args = [] args = []
kwargs = {} kwargs = {}
if isinstance(options, collections.abc.Mapping): # Using kwargs. if isinstance(options, collections.Mapping): # Using kwargs.
kwargs = options kwargs = options
else: else:
args = options args = options
if "backend" in kwargs: # Specific backend for this rule. middleware_instance = self.backend_factory(*args, **kwargs)
factory = import_member(kwargs["backend"])
del kwargs["backend"]
else: # Fallback to global backend.
factory = self.backend_factory
middleware_instance = factory(*args, **kwargs)
self.dispatcher.middlewares.append((key, middleware_instance)) self.dispatcher.middlewares.append((key, middleware_instance))
def process_download_response(self, request, response):
"""Use :attr:`dispatcher` to process download response."""
return self.dispatcher.process_download_response(request, response)
class NoRedirectionMatch(Exception): class NoRedirectionMatch(Exception):
"""Response object does not match redirection rules.""" """Response object does not match redirection rules."""
@ -179,13 +159,8 @@ class NoRedirectionMatch(Exception):
class ProxiedDownloadMiddleware(RealDownloadMiddleware): class ProxiedDownloadMiddleware(RealDownloadMiddleware):
"""Base class for middlewares that use optimizations of reverse proxies.""" """Base class for middlewares that use optimizations of reverse proxies."""
def __init__(self, source_dir=None, source_url=None, destination_url=None):
def __init__(
self, get_response, source_dir=None, source_url=None, destination_url=None
):
"""Constructor.""" """Constructor."""
super().__init__(get_response)
self.source_dir = source_dir self.source_dir = source_dir
self.source_url = source_url self.source_url = source_url
self.destination_url = destination_url self.destination_url = destination_url
@ -193,7 +168,7 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware):
def get_redirect_url(self, response): def get_redirect_url(self, response):
"""Return redirect URL for file wrapped into response.""" """Return redirect URL for file wrapped into response."""
url = None url = None
file_url = "" file_url = ''
if self.source_url: if self.source_url:
try: try:
file_url = response.file.url file_url = response.file.url
@ -201,9 +176,9 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware):
pass pass
else: else:
if file_url.startswith(self.source_url): if file_url.startswith(self.source_url):
file_url = file_url[len(self.source_url) :] file_url = file_url[len(self.source_url):]
url = file_url url = file_url
file_name = "" file_name = ''
if url is None and self.source_dir: if url is None and self.source_dir:
try: try:
file_name = response.file.name file_name = response.file.name
@ -212,21 +187,17 @@ class ProxiedDownloadMiddleware(RealDownloadMiddleware):
else: else:
if file_name.startswith(self.source_dir): if file_name.startswith(self.source_dir):
file_name = os.path.relpath(file_name, self.source_dir) file_name = os.path.relpath(file_name, self.source_dir)
url = file_name.replace(os.path.sep, "/") url = file_name.replace(os.path.sep, '/')
if url is None: if url is None:
message = ( message = ("""Couldn't capture/convert file attributes into a """
"""Couldn't capture/convert file attributes into a """ """redirection. """
"""redirection. """ """``source_url`` is "%(source_url)s", """
"""``source_url`` is "%(source_url)s", """ """file's URL is "%(file_url)s". """
"""file's URL is "%(file_url)s". """ """``source_dir`` is "%(source_dir)s", """
"""``source_dir`` is "%(source_dir)s", """ """file's name is "%(file_name)s". """
"""file's name is "%(file_name)s". """ % {'source_url': self.source_url,
% { 'file_url': file_url,
"source_url": self.source_url, 'source_dir': self.source_dir,
"file_url": file_url, 'file_name': file_name})
"source_dir": self.source_dir,
"file_name": file_name,
}
)
raise NoRedirectionMatch(message) raise NoRedirectionMatch(message)
return "/".join((self.destination_url.rstrip("/"), url.lstrip("/"))) return '/'.join((self.destination_url.rstrip('/'), url.lstrip('/')))

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""Optimizations for Nginx. """Optimizations for Nginx.
See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and
@ -5,9 +6,9 @@ See also `Nginx X-accel documentation <http://wiki.nginx.org/X-accel>`_ and
</optimizations/nginx>`. </optimizations/nginx>`.
""" """
# API shortcuts. # API shortcuts.
from django_downloadview.nginx.decorators import x_accel_redirect # NoQA from django_downloadview.nginx.decorators import x_accel_redirect # NoQA
from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware # NoQA
from django_downloadview.nginx.response import XAccelRedirectResponse # NoQA from django_downloadview.nginx.response import XAccelRedirectResponse # NoQA
from django_downloadview.nginx.tests import assert_x_accel_redirect # NoQA from django_downloadview.nginx.tests import assert_x_accel_redirect # NoQA
from django_downloadview.nginx.middlewares import ( # NoQA
XAccelRedirectMiddleware)

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Decorators to apply Nginx X-Accel on a specific view.""" """Decorators to apply Nginx X-Accel on a specific view."""
from django_downloadview.decorators import DownloadDecorator from django_downloadview.decorators import DownloadDecorator
from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware from django_downloadview.nginx.middlewares import XAccelRedirectMiddleware

View file

@ -3,10 +3,8 @@ import warnings
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django_downloadview.middlewares import ( from django_downloadview.middlewares import (ProxiedDownloadMiddleware,
NoRedirectionMatch, NoRedirectionMatch)
ProxiedDownloadMiddleware,
)
from django_downloadview.nginx.response import XAccelRedirectResponse from django_downloadview.nginx.response import XAccelRedirectResponse
@ -19,26 +17,15 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
:py:class:`django_downloadview.decorators.DownloadDecorator`. :py:class:`django_downloadview.decorators.DownloadDecorator`.
""" """
def __init__(self, source_dir=None, source_url=None, destination_url=None,
def __init__( expires=None, with_buffering=None, limit_rate=None,
self, media_root=None, media_url=None):
get_response=None,
source_dir=None,
source_url=None,
destination_url=None,
expires=None,
with_buffering=None,
limit_rate=None,
media_root=None,
media_url=None,
):
"""Constructor.""" """Constructor."""
if media_url is not None: if media_url is not None:
warnings.warn( warnings.warn("%s ``media_url`` is deprecated. Use "
"%s ``media_url`` is deprecated. Use " "``destination_url`` instead."
"``destination_url`` instead." % self.__class__.__name__, % self.__class__.__name__,
DeprecationWarning, DeprecationWarning)
)
if destination_url is None: if destination_url is None:
destination_url = media_url destination_url = media_url
else: else:
@ -46,20 +33,18 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
else: else:
destination_url = destination_url destination_url = destination_url
if media_root is not None: if media_root is not None:
warnings.warn( warnings.warn("%s ``media_root`` is deprecated. Use "
"%s ``media_root`` is deprecated. Use " "``source_dir`` instead." % self.__class__.__name__,
"``source_dir`` instead." % self.__class__.__name__, DeprecationWarning)
DeprecationWarning,
)
if source_dir is None: if source_dir is None:
source_dir = media_root source_dir = media_root
else: else:
source_dir = source_dir source_dir = source_dir
else: else:
source_dir = source_dir source_dir = source_dir
super(XAccelRedirectMiddleware, self).__init__(source_dir,
super().__init__(get_response, source_dir, source_url, destination_url) source_url,
destination_url)
self.expires = expires self.expires = expires
self.with_buffering = with_buffering self.with_buffering = with_buffering
self.limit_rate = limit_rate self.limit_rate = limit_rate
@ -77,16 +62,13 @@ class XAccelRedirectMiddleware(ProxiedDownloadMiddleware):
expires = response.expires expires = response.expires
except AttributeError: except AttributeError:
expires = None expires = None
return XAccelRedirectResponse( return XAccelRedirectResponse(redirect_url=redirect_url,
redirect_url=redirect_url, content_type=response['Content-Type'],
content_type=response["Content-Type"], basename=response.basename,
basename=response.basename, expires=expires,
expires=expires, with_buffering=self.with_buffering,
with_buffering=self.with_buffering, limit_rate=self.limit_rate,
limit_rate=self.limit_rate, attachment=response.attachment)
attachment=response.attachment,
headers=response.headers,
)
class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware): class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware):
@ -123,20 +105,16 @@ class SingleXAccelRedirectMiddleware(XAccelRedirectMiddleware):
Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``. Replaced by ``NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL``.
""" """
def __init__(self):
def __init__(self, get_response=None):
"""Use Django settings as configuration.""" """Use Django settings as configuration."""
if settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is None: if settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is None:
raise ImproperlyConfigured( raise ImproperlyConfigured(
"settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is " 'settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL is '
"required by %s middleware" % self.__class__.__name__ 'required by %s middleware' % self.__class__.__name__)
) super(SingleXAccelRedirectMiddleware, self).__init__(
super().__init__(
get_response=get_response,
source_dir=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR, source_dir=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR,
source_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL, source_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL,
destination_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL, destination_url=settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL,
expires=settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES, expires=settings.NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES,
with_buffering=settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING, with_buffering=settings.NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING,
limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE, limit_rate=settings.NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE)
)

View file

@ -1,43 +1,34 @@
# -*- coding: utf-8 -*-
"""Nginx's specific responses.""" """Nginx's specific responses."""
from datetime import timedelta from datetime import timedelta
from django.utils.timezone import now from django.utils.timezone import now
from django_downloadview.response import ProxiedDownloadResponse, content_disposition from django_downloadview.response import ProxiedDownloadResponse
from django_downloadview.utils import content_type_to_charset, url_basename from django_downloadview.utils import content_type_to_charset, url_basename
class XAccelRedirectResponse(ProxiedDownloadResponse): class XAccelRedirectResponse(ProxiedDownloadResponse):
"Http response that delegates serving file to Nginx via X-Accel headers." "Http response that delegates serving file to Nginx via X-Accel headers."
def __init__(self, redirect_url, content_type, basename=None, expires=None,
def __init__( with_buffering=None, limit_rate=None, attachment=True):
self,
redirect_url,
content_type,
basename=None,
expires=None,
with_buffering=None,
limit_rate=None,
attachment=True,
headers=None,
):
"""Return a HttpResponse with headers for Nginx X-Accel-Redirect.""" """Return a HttpResponse with headers for Nginx X-Accel-Redirect."""
# content-type must be porvided only as keyword argument to response super(XAccelRedirectResponse, self).__init__(content_type=content_type)
if headers and content_type:
headers.pop("Content-Type", None)
super().__init__(content_type=content_type, headers=headers)
if attachment: if attachment:
self.basename = basename or url_basename(redirect_url, content_type) self.basename = basename or url_basename(redirect_url,
self["Content-Disposition"] = content_disposition(self.basename) content_type)
self["X-Accel-Redirect"] = redirect_url self['Content-Disposition'] = 'attachment; filename={name}'.format(
self["X-Accel-Charset"] = content_type_to_charset(content_type) name=self.basename)
self['X-Accel-Redirect'] = redirect_url
self['X-Accel-Charset'] = content_type_to_charset(content_type)
if with_buffering is not None: if with_buffering is not None:
self["X-Accel-Buffering"] = with_buffering and "yes" or "no" self['X-Accel-Buffering'] = with_buffering and 'yes' or 'no'
if expires: if expires:
expire_seconds = timedelta(expires - now()).seconds expire_seconds = timedelta(expires - now()).seconds
self["X-Accel-Expires"] = expire_seconds self['X-Accel-Expires'] = expire_seconds
elif expires is not None: # We explicitely want it off. elif expires is not None: # We explicitely want it off.
self["X-Accel-Expires"] = "off" self['X-Accel-Expires'] = 'off'
if limit_rate is not None: if limit_rate is not None:
self["X-Accel-Limit-Rate"] = limit_rate and "%d" % limit_rate or "off" self['X-Accel-Limit-Rate'] = (limit_rate
and '%d' % limit_rate
or 'off')

View file

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""Django settings around Nginx X-Accel. """Django settings around Nginx X-Accel.
.. warning:: .. warning::
@ -7,31 +8,26 @@
for details. for details.
""" """
import warnings import warnings
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
# In version 1.3, former XAccelRedirectMiddleware has been renamed to # In version 1.3, former XAccelRedirectMiddleware has been renamed to
# SingleXAccelRedirectMiddleware. So tell the users. # SingleXAccelRedirectMiddleware. So tell the users.
deprecated_middleware = "django_downloadview.nginx.XAccelRedirectMiddleware" middleware = 'django_downloadview.nginx.XAccelRedirectMiddleware'
if middleware in settings.MIDDLEWARE_CLASSES:
if deprecated_middleware in settings.MIDDLEWARE:
raise ImproperlyConfigured( raise ImproperlyConfigured(
"{deprecated_middleware} middleware has been renamed as of " '{middleware} middleware has been renamed as of django-downloadview '
"django-downloadview version 1.3. You may use " 'version 1.3. You may use '
'"django_downloadview.nginx.SingleXAccelRedirectMiddleware" instead, ' '"django_downloadview.nginx.SingleXAccelRedirectMiddleware" instead, '
'or upgrade to "django_downloadview.SmartDownloadDispatcher". ' 'or upgrade to "django_downloadview.SmartDownloadDispatcher". ')
)
deprecated_msg = ( deprecated_msg = 'settings.{deprecated} is deprecated. You should combine ' \
"settings.{deprecated} is deprecated. You should combine " '"django_downloadview.SmartDownloadDispatcher" with ' \
'"django_downloadview.SmartDownloadDispatcher" with ' 'with DOWNLOADVIEW_BACKEND and DOWNLOADVIEW_RULES instead.'
"with DOWNLOADVIEW_BACKEND and DOWNLOADVIEW_RULES instead."
)
#: Default value for X-Accel-Buffering header. #: Default value for X-Accel-Buffering header.
@ -46,9 +42,10 @@ deprecated_msg = (
#: If set to ``False``, Nginx buffering is disabled. #: If set to ``False``, Nginx buffering is disabled.
#: If set to ``True``, Nginx buffering is enabled. #: If set to ``True``, Nginx buffering is enabled.
DEFAULT_WITH_BUFFERING = None DEFAULT_WITH_BUFFERING = None
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING" setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING'
if hasattr(settings, setting_name): if hasattr(settings, setting_name):
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) warnings.warn(deprecated_msg.format(deprecated=setting_name),
DeprecationWarning)
if not hasattr(settings, setting_name): if not hasattr(settings, setting_name):
setattr(settings, setting_name, DEFAULT_WITH_BUFFERING) setattr(settings, setting_name, DEFAULT_WITH_BUFFERING)
@ -64,9 +61,10 @@ if not hasattr(settings, setting_name):
#: If set to ``False``, Nginx limit rate is disabled. #: If set to ``False``, Nginx limit rate is disabled.
#: Else, it indicates the limit rate in bytes. #: Else, it indicates the limit rate in bytes.
DEFAULT_LIMIT_RATE = None DEFAULT_LIMIT_RATE = None
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE" setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE'
if hasattr(settings, setting_name): if hasattr(settings, setting_name):
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) warnings.warn(deprecated_msg.format(deprecated=setting_name),
DeprecationWarning)
if not hasattr(settings, setting_name): if not hasattr(settings, setting_name):
setattr(settings, setting_name, DEFAULT_LIMIT_RATE) setattr(settings, setting_name, DEFAULT_LIMIT_RATE)
@ -82,43 +80,49 @@ if not hasattr(settings, setting_name):
#: If set to ``False``, Nginx buffering is disabled. #: If set to ``False``, Nginx buffering is disabled.
#: Else, it indicates the expiration delay, in seconds. #: Else, it indicates the expiration delay, in seconds.
DEFAULT_EXPIRES = None DEFAULT_EXPIRES = None
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES" setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES'
if hasattr(settings, setting_name): if hasattr(settings, setting_name):
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) warnings.warn(deprecated_msg.format(deprecated=setting_name),
DeprecationWarning)
if not hasattr(settings, setting_name): if not hasattr(settings, setting_name):
setattr(settings, setting_name, DEFAULT_EXPIRES) setattr(settings, setting_name, DEFAULT_EXPIRES)
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR. #: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR.
DEFAULT_SOURCE_DIR = settings.MEDIA_ROOT DEFAULT_SOURCE_DIR = settings.MEDIA_ROOT
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT" setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT'
if hasattr(settings, setting_name): if hasattr(settings, setting_name):
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) warnings.warn(deprecated_msg.format(deprecated=setting_name),
DeprecationWarning)
DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR" setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR'
if hasattr(settings, setting_name): if hasattr(settings, setting_name):
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) warnings.warn(deprecated_msg.format(deprecated=setting_name),
DeprecationWarning)
if not hasattr(settings, setting_name): if not hasattr(settings, setting_name):
setattr(settings, setting_name, DEFAULT_SOURCE_DIR) setattr(settings, setting_name, DEFAULT_SOURCE_DIR)
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL. #: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL.
DEFAULT_SOURCE_URL = settings.MEDIA_URL DEFAULT_SOURCE_URL = settings.MEDIA_URL
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL" setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL'
if hasattr(settings, setting_name): if hasattr(settings, setting_name):
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) warnings.warn(deprecated_msg.format(deprecated=setting_name),
DeprecationWarning)
if not hasattr(settings, setting_name): if not hasattr(settings, setting_name):
setattr(settings, setting_name, DEFAULT_SOURCE_URL) setattr(settings, setting_name, DEFAULT_SOURCE_URL)
#: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL. #: Default value for settings.NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL.
DEFAULT_DESTINATION_URL = None DEFAULT_DESTINATION_URL = None
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL" setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL'
if hasattr(settings, setting_name): if hasattr(settings, setting_name):
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) warnings.warn(deprecated_msg.format(deprecated=setting_name),
DeprecationWarning)
DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL DEFAULT_SOURCE_DIR = settings.NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL
setting_name = "NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL" setting_name = 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL'
if hasattr(settings, setting_name): if hasattr(settings, setting_name):
warnings.warn(deprecated_msg.format(deprecated=setting_name), DeprecationWarning) warnings.warn(deprecated_msg.format(deprecated=setting_name),
DeprecationWarning)
if not hasattr(settings, setting_name): if not hasattr(settings, setting_name):
setattr(settings, setting_name, DEFAULT_DESTINATION_URL) setattr(settings, setting_name, DEFAULT_DESTINATION_URL)

View file

@ -1,13 +1,12 @@
from django_downloadview.nginx.response import XAccelRedirectResponse from django_downloadview.nginx.response import XAccelRedirectResponse
class XAccelRedirectValidator: class XAccelRedirectValidator(object):
"""Utility class to validate XAccelRedirectResponse instances. """Utility class to validate XAccelRedirectResponse instances.
See also :py:func:`assert_x_accel_redirect` shortcut function. See also :py:func:`assert_x_accel_redirect` shortcut function.
""" """
def __call__(self, test_case, response, **assertions): def __call__(self, test_case, response, **assertions):
"""Assert that ``response`` is a valid X-Accel-Redirect response. """Assert that ``response`` is a valid X-Accel-Redirect response.
@ -36,8 +35,8 @@ class XAccelRedirectValidator:
""" """
self.assert_x_accel_redirect_response(test_case, response) self.assert_x_accel_redirect_response(test_case, response)
for key, value in assertions.items(): for key, value in assertions.iteritems():
assert_func = getattr(self, "assert_%s" % key) assert_func = getattr(self, 'assert_%s' % key)
assert_func(test_case, response, value) assert_func(test_case, response, value)
def assert_x_accel_redirect_response(self, test_case, response): def assert_x_accel_redirect_response(self, test_case, response):
@ -47,45 +46,45 @@ class XAccelRedirectValidator:
test_case.assertEqual(response.basename, value) test_case.assertEqual(response.basename, value)
def assert_content_type(self, test_case, response, value): def assert_content_type(self, test_case, response, value):
test_case.assertEqual(response["Content-Type"], value) test_case.assertEqual(response['Content-Type'], value)
def assert_redirect_url(self, test_case, response, value): def assert_redirect_url(self, test_case, response, value):
test_case.assertEqual(response["X-Accel-Redirect"], value) test_case.assertEqual(response['X-Accel-Redirect'], value)
def assert_charset(self, test_case, response, value): def assert_charset(self, test_case, response, value):
test_case.assertEqual(response["X-Accel-Charset"], value) test_case.assertEqual(response['X-Accel-Charset'], value)
def assert_with_buffering(self, test_case, response, value): def assert_with_buffering(self, test_case, response, value):
header = "X-Accel-Buffering" header = 'X-Accel-Buffering'
if value is None: if value is None:
test_case.assertFalse(header in response) test_case.assertFalse(header in response)
elif value: elif value:
test_case.assertEqual(header, "yes") test_case.assertEqual(header, 'yes')
else: else:
test_case.assertEqual(header, "no") test_case.assertEqual(header, 'no')
def assert_expires(self, test_case, response, value): def assert_expires(self, test_case, response, value):
header = "X-Accel-Expires" header = 'X-Accel-Expires'
if value is None: if value is None:
test_case.assertFalse(header in response) test_case.assertFalse(header in response)
elif not value: elif not value:
test_case.assertEqual(header, "off") test_case.assertEqual(header, 'off')
else: else:
test_case.assertEqual(header, value) test_case.assertEqual(header, value)
def assert_limit_rate(self, test_case, response, value): def assert_limit_rate(self, test_case, response, value):
header = "X-Accel-Limit-Rate" header = 'X-Accel-Limit-Rate'
if value is None: if value is None:
test_case.assertFalse(header in response) test_case.assertFalse(header in response)
elif not value: elif not value:
test_case.assertEqual(header, "off") test_case.assertEqual(header, 'off')
else: else:
test_case.assertEqual(header, value) test_case.assertEqual(header, value)
def assert_attachment(self, test_case, response, value): def assert_attachment(self, test_case, response, value):
header = "Content-Disposition" header = 'Content-Disposition'
if value: if value:
test_case.assertTrue(response[header].startswith("attachment")) test_case.assertTrue(response[header].startswith('attachment'))
else: else:
test_case.assertFalse(header in response) test_case.assertFalse(header in response)

View file

@ -1,93 +1,10 @@
# -*- coding: utf-8 -*-
""":py:class:`django.http.HttpResponse` subclasses.""" """:py:class:`django.http.HttpResponse` subclasses."""
import mimetypes
import os import os
import re import mimetypes
import unicodedata
from urllib.parse import quote
from django.conf import settings from django.conf import settings
from django.http import HttpResponse, StreamingHttpResponse from django.http import HttpResponse, StreamingHttpResponse
from django.utils.encoding import force_str
def encode_basename_ascii(value):
"""Return US-ASCII encoded ``value`` for Content-Disposition header.
>>> print(encode_basename_ascii(u'éà'))
ea
Spaces are converted to underscores.
>>> print(encode_basename_ascii(' '))
_
Of course, ASCII values are not modified.
>>> print(encode_basename_ascii('ea'))
ea
>>> print(encode_basename_ascii(b'ea'))
ea
"""
if isinstance(value, bytes):
value = value.decode("utf-8")
ascii_basename = str(value)
ascii_basename = unicodedata.normalize("NFKD", ascii_basename)
ascii_basename = ascii_basename.encode("ascii", "ignore")
ascii_basename = ascii_basename.decode("ascii")
ascii_basename = re.sub(r"[\s]", "_", ascii_basename)
return ascii_basename
def encode_basename_utf8(value):
"""Return UTF-8 encoded ``value`` for use in Content-Disposition header.
>>> print(encode_basename_utf8(u' .txt'))
%20.txt
>>> print(encode_basename_utf8(u'éà'))
%C3%A9%C3%A0
"""
return quote(force_str(value))
def content_disposition(filename):
"""Return value of ``Content-Disposition`` header with 'attachment'.
>>> print(content_disposition('demo.txt'))
attachment; filename="demo.txt"
If filename is empty, only "attachment" is returned.
>>> print(content_disposition(''))
attachment
If filename contains non US-ASCII characters, the returned value contains
UTF-8 encoded filename and US-ASCII fallback.
>>> print(content_disposition(u'é.txt'))
attachment; filename="e.txt"; filename*=UTF-8''%C3%A9.txt
"""
if not filename:
return "attachment"
# ASCII filenames are quoted and must ensure escape sequences
# in the filename won't break out of the quoted header value
# which can permit a reflected file download attack. The UTF-8
# version is immune because it's not quoted.
ascii_filename = (
encode_basename_ascii(filename).replace("\\", "\\\\").replace('"', r"\"")
)
utf8_filename = encode_basename_utf8(filename)
if ascii_filename == utf8_filename: # ASCII only.
return f'attachment; filename="{ascii_filename}"'
else:
return (
f'attachment; filename="{ascii_filename}"; '
f"filename*=UTF-8''{utf8_filename}"
)
class DownloadResponse(StreamingHttpResponse): class DownloadResponse(StreamingHttpResponse):
@ -97,7 +14,30 @@ class DownloadResponse(StreamingHttpResponse):
where :attr:`~django.http.StreamingHttpResponse.streaming_content` is a where :attr:`~django.http.StreamingHttpResponse.streaming_content` is a
file wrapper. file wrapper.
Constructor differs a bit from :class:`~django.http.response.HttpResponse`. Constructor differs a bit from :class:`~django.http.response.HttpResponse`:
``file_instance``
A :doc:`file wrapper instance </files>`, such as
:class:`~django.core.files.base.File`.
``attachement``
Boolean. Whether to return the file as attachment or not.
Affects ``Content-Disposition`` header.
``basename``
Unicode. Client-side name of the file to stream.
Only used if ``attachment`` is ``True``.
Affects ``Content-Disposition`` header.
``status``
HTTP status code.
``content_type``
Value for ``Content-Type`` header.
If ``None``, then mime-type and encoding will be populated by the
response (default implementation uses mimetypes, based on file
name).
Here are some highlights to understand internal mechanisms and motivations: Here are some highlights to understand internal mechanisms and motivations:
@ -108,8 +48,8 @@ class DownloadResponse(StreamingHttpResponse):
generator-iterator) that produces the output in a block-by-block generator-iterator) that produces the output in a block-by-block
fashion. fashion.
* Django WSGI handler (application implementation) returns response object * `Django WSGI handler (application implementation) return response object
(see :mod:`django.core.handlers.wsgi`). <https://github.com/django/django/blob/fd1279a44df3b9a837453cd79fd0fbcf81bae39d/django/core/handlers/wsgi.py#L268>`_.
* :class:`django.http.HttpResponse` and subclasses are iterators. * :class:`django.http.HttpResponse` and subclasses are iterators.
@ -123,56 +63,20 @@ class DownloadResponse(StreamingHttpResponse):
attributes (size, name, ...). attributes (size, name, ...).
""" """
def __init__(self, file_instance, attachment=True, basename=None,
def __init__( status=200, content_type=None):
self, """Constructor."""
file_instance,
attachment=True,
basename=None,
status=200,
content_type=None,
file_mimetype=None,
file_encoding=None,
):
"""Constructor.
:param content_type: Value for ``Content-Type`` header.
If ``None``, then mime-type and encoding will be
populated by the response (default implementation
uses :mod:`mimetypes`, based on file name).
"""
#: A :doc:`file wrapper instance </files>`, such as
#: :class:`~django.core.files.base.File`.
self.file = file_instance self.file = file_instance
super().__init__( super(DownloadResponse, self).__init__(streaming_content=self.file,
streaming_content=self.file, status=status, content_type=content_type status=status,
) content_type=content_type)
#: Client-side name of the file to stream.
#: Only used if ``attachment`` is ``True``.
#: Affects ``Content-Disposition`` header.
self.basename = basename self.basename = basename
#: Whether to return the file as attachment or not.
#: Affects ``Content-Disposition`` header.
self.attachment = attachment self.attachment = attachment
if not content_type: if not content_type:
del self["Content-Type"] # Will be set later. del self['Content-Type'] # Will be set later.
#: Value for file's mimetype.
#: If ``None`` (the default), then the file's mimetype will be guessed
#: via Python's :mod:`mimetypes`. See :meth:`get_mime_type`.
self.file_mimetype = file_mimetype
#: Value for file's encoding. If ``None`` (the default), then the
#: file's encoding will be guessed via Python's :mod:`mimetypes`. See
#: :meth:`get_encoding`.
self.file_encoding = file_encoding
# Apply default headers. # Apply default headers.
for header, value in self.default_headers.items(): for header, value in self.default_headers.items():
if header not in self: if not header in self:
self[header] = value # Does self support setdefault? self[header] = value # Does self support setdefault?
@property @property
@ -182,26 +86,32 @@ class DownloadResponse(StreamingHttpResponse):
Uses an internal ``_default_headers`` cache. Uses an internal ``_default_headers`` cache.
Default values are computed if only cache hasn't been set. Default values are computed if only cache hasn't been set.
``Content-Disposition`` header is encoded according to `RFC 5987
<http://tools.ietf.org/html/rfc5987>`_. See also
http://stackoverflow.com/questions/93551/.
""" """
try: try:
return self._default_headers return self._default_headers
except AttributeError: except AttributeError:
headers = {} headers = {}
headers["Content-Type"] = self.get_content_type() headers['Content-Type'] = self.get_content_type()
try: try:
headers["Content-Length"] = self.file.size headers['Content-Length'] = self.file.size
except (AttributeError, NotImplementedError): except (AttributeError, NotImplementedError):
pass # Generated files. pass # Generated files.
if self.attachment: if self.attachment:
basename = self.get_basename() headers['Content-Disposition'] = 'attachment; filename=%s' \
headers["Content-Disposition"] = content_disposition(basename) % self.get_basename()
self._default_headers = headers self._default_headers = headers
return self._default_headers return self._default_headers
def items(self):
"""Return iterable of (header, value).
This method is called by http handlers just before WSGI's
start_response() is called... but it is not called by
django.test.ClientHandler! :'(
"""
return super(DownloadResponse, self).items()
def get_basename(self): def get_basename(self):
"""Return basename.""" """Return basename."""
if self.basename: if self.basename:
@ -214,21 +124,19 @@ class DownloadResponse(StreamingHttpResponse):
try: try:
return self.file.content_type return self.file.content_type
except AttributeError: except AttributeError:
return f"{self.get_mime_type()}; charset={self.get_charset()}" content_type_template = '{mime_type}; charset={charset}'
return content_type_template.format(mime_type=self.get_mime_type(),
charset=self.get_charset())
def get_mime_type(self): def get_mime_type(self):
"""Return mime-type of the file.""" """Return mime-type of the file."""
if self.file_mimetype is not None: default_mime_type = 'application/octet-stream'
return self.file_mimetype
default_mime_type = "application/octet-stream"
basename = self.get_basename() basename = self.get_basename()
mime_type, encoding = mimetypes.guess_type(basename) mime_type, encoding = mimetypes.guess_type(basename)
return mime_type or default_mime_type return mime_type or default_mime_type
def get_encoding(self): def get_encoding(self):
"""Return encoding of the file to serve.""" """Return encoding of the file to serve."""
if self.file_encoding is not None:
return self.file_encoding
basename = self.get_basename() basename = self.get_basename()
mime_type, encoding = mimetypes.guess_type(basename) mime_type, encoding = mimetypes.guess_type(basename)
return encoding return encoding

View file

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""Port of django-sendfile in django-downloadview."""
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django_downloadview.views.storage import StorageDownloadView
def sendfile(request, filename, attachment=False, attachment_filename=None,
mimetype=None, encoding=None):
"""Port of django-sendfile's API in django-downloadview.
Instantiates a :class:`~django.core.files.storage.FileSystemStorage` with
``settings.SENDFILE_ROOT`` as root folder. Then uses
:class:`StorageDownloadView` to stream the file by ``filename``.
"""
storage = FileSystemStorage(location=settings.SENDFILE_ROOT)
view = StorageDownloadView().as_view(storage=storage,
path=filename,
attachment=attachment,
basename=attachment_filename)
return view(request)

View file

@ -1,27 +0,0 @@
"""Port of django-sendfile in django-downloadview."""
from django_downloadview.views.path import PathDownloadView
def sendfile(
request,
filename,
attachment=False,
attachment_filename=None,
mimetype=None,
encoding=None,
):
"""Port of django-sendfile's API in django-downloadview.
Instantiates a :class:`~django_downloadview.views.path.PathDownloadView` to
stream the file by ``filename``.
"""
view = PathDownloadView.as_view(
path=filename,
attachment=attachment,
basename=attachment_filename,
mimetype=mimetype,
encoding=encoding,
)
return view(request)

View file

@ -1,22 +0,0 @@
from django.core.files.storage import FileSystemStorage
from django.core.signing import TimestampSigner
class SignedURLMixin:
"""
Mixin for generating signed file URLs with compatible storage backends.
Adds X-Signature query parameters to the normal URLs generated by the storage class.
"""
def url(self, name):
path = super().url(name)
signer = TimestampSigner()
signature = signer.sign(path)
return "{}?X-Signature={}".format(path, signature)
class SignedFileSystemStorage(SignedURLMixin, FileSystemStorage):
"""
Specialized filesystem storage that signs file URLs for clients.
"""

View file

@ -1,12 +1,11 @@
"""Testing utilities."""
import shutil import shutil
import tempfile import tempfile
from django.conf import settings from django.conf import settings
from django.test.utils import override_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.middlewares import is_download_response
from django_downloadview.response import encode_basename_ascii, encode_basename_utf8
def setup_view(view, request, *args, **kwargs): def setup_view(view, request, *args, **kwargs):
@ -45,7 +44,7 @@ class temporary_media_root(override_settings):
Use this function as a context manager: Use this function as a context manager:
>>> from django_downloadview.test import temporary_media_root >>> from django_downloadview.test import temporary_media_root
>>> from django.conf import settings # NoQA >>> from django.conf import settings
>>> global_media_root = settings.MEDIA_ROOT >>> global_media_root = settings.MEDIA_ROOT
>>> with temporary_media_root(): >>> with temporary_media_root():
... global_media_root == settings.MEDIA_ROOT ... global_media_root == settings.MEDIA_ROOT
@ -65,24 +64,22 @@ class temporary_media_root(override_settings):
True True
""" """
def enable(self): def enable(self):
"""Create a temporary directory and use it to override """Create a temporary directory and use it to override
settings.MEDIA_ROOT.""" settings.MEDIA_ROOT."""
tmp_dir = tempfile.mkdtemp() tmp_dir = tempfile.mkdtemp()
self.options["MEDIA_ROOT"] = tmp_dir self.options['MEDIA_ROOT'] = tmp_dir
super().enable() super(temporary_media_root, self).enable()
def disable(self): def disable(self):
"""Remove directory settings.MEDIA_ROOT then restore original """Remove directory settings.MEDIA_ROOT then restore original
setting.""" setting."""
shutil.rmtree(settings.MEDIA_ROOT) shutil.rmtree(settings.MEDIA_ROOT)
super().disable() super(temporary_media_root, self).disable()
class DownloadResponseValidator(object): class DownloadResponseValidator(object):
"""Utility class to validate DownloadResponse instances.""" """Utility class to validate DownloadResponse instances."""
def __call__(self, test_case, response, **assertions): def __call__(self, test_case, response, **assertions):
"""Assert that ``response`` is a valid DownloadResponse instance. """Assert that ``response`` is a valid DownloadResponse instance.
@ -102,8 +99,8 @@ class DownloadResponseValidator(object):
""" """
self.assert_download_response(test_case, response) self.assert_download_response(test_case, response)
for key, value in assertions.items(): for key, value in assertions.iteritems():
assert_func = getattr(self, "assert_%s" % key) assert_func = getattr(self, 'assert_%s' % key)
assert_func(test_case, response, value) assert_func(test_case, response, value)
def assert_download_response(self, test_case, response): def assert_download_response(self, test_case, response):
@ -111,48 +108,22 @@ class DownloadResponseValidator(object):
def assert_basename(self, test_case, response, value): def assert_basename(self, test_case, response, value):
"""Implies ``attachement is True``.""" """Implies ``attachement is True``."""
ascii_name = encode_basename_ascii(value) test_case.assertTrue(
utf8_name = encode_basename_utf8(value) response['Content-Disposition'].endswith(
check_utf8 = False 'filename={name}'.format(name=value)))
check_ascii = False
if ascii_name == utf8_name: # Only ASCII characters.
check_ascii = True
if "filename*=" in response["Content-Disposition"]:
check_utf8 = True
else:
check_utf8 = True
if "filename=" in response["Content-Disposition"]:
check_ascii = True
if check_ascii:
test_case.assertIn(
f'filename="{ascii_name}"',
response["Content-Disposition"],
)
if check_utf8:
test_case.assertIn(
f"filename*=UTF-8''{utf8_name}",
response["Content-Disposition"],
)
def assert_content_type(self, test_case, response, value): def assert_content_type(self, test_case, response, value):
test_case.assertEqual(response["Content-Type"], value) test_case.assertEqual(response['Content-Type'], value)
def assert_mime_type(self, test_case, response, value): def assert_mime_type(self, test_case, response, value):
test_case.assertTrue(response["Content-Type"].startswith(value)) test_case.assertTrue(response['Content-Type'].startswith(value))
def assert_content(self, test_case, response, value): def assert_content(self, test_case, response, value):
"""Assert value equals response's content (byte comparison).""" test_case.assertEqual(''.join(response.streaming_content), value)
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): def assert_attachment(self, test_case, response, value):
if value: test_case.assertEqual('attachment;' in response['Content-Disposition'],
test_case.assertTrue("attachment;" in response["Content-Disposition"]) value)
else:
test_case.assertTrue(
"Content-Disposition" not in response
or "attachment;" not in response["Content-Disposition"]
)
def assert_download_response(test_case, response, **assertions): def assert_download_response(test_case, response, **assertions):

View file

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

View file

@ -1,17 +1,16 @@
# -*- coding: utf-8 -*-
"""Test suite around :mod:`django_downloadview.api` and deprecation plan.""" """Test suite around :mod:`django_downloadview.api` and deprecation plan."""
from importlib import import_module, reload
import unittest import unittest
import warnings import warnings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
import django.test import django.test
from django.test.utils import override_settings from django.test.utils import override_settings
from django.utils.importlib import import_module
class APITestCase(unittest.TestCase): class APITestCase(unittest.TestCase):
"""Make sure django_downloadview exposes API.""" """Make sure django_downloadview exposes API."""
def assert_module_attributes(self, module_path, attribute_names): def assert_module_attributes(self, module_path, attribute_names):
"""Assert imported ``module_path`` has ``attribute_names``.""" """Assert imported ``module_path`` has ``attribute_names``."""
module = import_module(module_path) module = import_module(module_path)
@ -20,9 +19,8 @@ class APITestCase(unittest.TestCase):
if not hasattr(module, attribute_name): if not hasattr(module, attribute_name):
missing_attributes.append(attribute_name) missing_attributes.append(attribute_name)
if missing_attributes: if missing_attributes:
self.fail( self.fail('Missing attributes in "{module}": {attributes}'.format(
'Missing attributes in "{module_path}": {", ".join(missing_attributes)}' module=module_path, attributes=', '.join(missing_attributes)))
)
def test_root_attributes(self): def test_root_attributes(self):
"""API is exposed in django_downloadview root package. """API is exposed in django_downloadview root package.
@ -36,92 +34,67 @@ class APITestCase(unittest.TestCase):
""" """
api = [ api = [
# Views: # Views:
"ObjectDownloadView", 'ObjectDownloadView',
"StorageDownloadView", 'StorageDownloadView',
"PathDownloadView", 'PathDownloadView',
"HTTPDownloadView", 'HTTPDownloadView',
"VirtualDownloadView", 'VirtualDownloadView',
"BaseDownloadView", 'BaseDownloadView',
"DownloadMixin", 'DownloadMixin',
# File wrappers: # File wrappers:
"StorageFile", 'StorageFile',
"HTTPFile", 'HTTPFile',
"VirtualFile", 'VirtualFile',
# Responses: # Responses:
"DownloadResponse", 'DownloadResponse',
"ProxiedDownloadResponse", 'ProxiedDownloadResponse',
# Middlewares: # Middlewares:
"BaseDownloadMiddleware", 'BaseDownloadMiddleware',
"DownloadDispatcherMiddleware", 'DownloadDispatcherMiddleware',
"SmartDownloadMiddleware", 'SmartDownloadMiddleware',
# Testing: # Testing:
"assert_download_response", 'assert_download_response',
"setup_view", 'setup_view',
"temporary_media_root", 'temporary_media_root',
# Utilities: # Utilities:
"StringIteratorIO", 'StringIteratorIO',
"sendfile", 'sendfile']
] self.assert_module_attributes('django_downloadview', api)
self.assert_module_attributes("django_downloadview", api)
def test_nginx_attributes(self): def test_nginx_attributes(self):
"""Nginx-related API is exposed in django_downloadview.nginx.""" """Nginx-related API is exposed in django_downloadview.nginx."""
api = [ api = [
"XAccelRedirectResponse", 'XAccelRedirectResponse',
"XAccelRedirectMiddleware", 'XAccelRedirectMiddleware',
"x_accel_redirect", 'x_accel_redirect',
"assert_x_accel_redirect", 'assert_x_accel_redirect']
] self.assert_module_attributes('django_downloadview.nginx', api)
self.assert_module_attributes("django_downloadview.nginx", api)
def test_apache_attributes(self):
"""Apache-related API is exposed in django_downloadview.apache."""
api = [
"XSendfileResponse",
"XSendfileMiddleware",
"x_sendfile",
"assert_x_sendfile",
]
self.assert_module_attributes("django_downloadview.apache", api)
def test_lighttpd_attributes(self):
"""Lighttpd-related API is exposed in django_downloadview.lighttpd."""
api = [
"XSendfileResponse",
"XSendfileMiddleware",
"x_sendfile",
"assert_x_sendfile",
]
self.assert_module_attributes("django_downloadview.lighttpd", api)
class DeprecatedAPITestCase(django.test.SimpleTestCase): class DeprecatedAPITestCase(django.test.SimpleTestCase):
"""Make sure using deprecated items raise DeprecationWarning.""" """Make sure using deprecated items raise DeprecationWarning."""
def test_nginx_x_accel_redirect_middleware(self): def test_nginx_x_accel_redirect_middleware(self):
"XAccelRedirectMiddleware in settings triggers ImproperlyConfigured." "XAccelRedirectMiddleware in settings triggers ImproperlyConfigured."
with override_settings( with override_settings(
MIDDLEWARE=["django_downloadview.nginx.XAccelRedirectMiddleware"], MIDDLEWARE_CLASSES=[
): 'django_downloadview.nginx.XAccelRedirectMiddleware']):
with self.assertRaises(ImproperlyConfigured): with self.assertRaises(ImproperlyConfigured):
import django_downloadview.nginx.settings import django_downloadview.nginx.settings
reload(django_downloadview.nginx.settings) reload(django_downloadview.nginx.settings)
def test_nginx_x_accel_redirect_global_settings(self): def test_nginx_x_accel_redirect_global_settings(self):
"""Global settings for Nginx middleware are deprecated.""" """Global settings for Nginx middleware are deprecated."""
settings_overrides = { settings_overrides = {
"NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING": True, 'NGINX_DOWNLOAD_MIDDLEWARE_WITH_BUFFERING': True,
"NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE": 32, 'NGINX_DOWNLOAD_MIDDLEWARE_LIMIT_RATE': 32,
"NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES": 3600, 'NGINX_DOWNLOAD_MIDDLEWARE_EXPIRES': 3600,
"NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT": "/", 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_ROOT': '/',
"NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR": "/", 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_DIR': '/',
"NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL": "/", 'NGINX_DOWNLOAD_MIDDLEWARE_SOURCE_URL': '/',
"NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL": "/", 'NGINX_DOWNLOAD_MIDDLEWARE_MEDIA_URL': '/',
"NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL": "/", 'NGINX_DOWNLOAD_MIDDLEWARE_DESTINATION_URL': '/',
} }
import django_downloadview.nginx.settings import django_downloadview.nginx.settings
missed_warnings = [] missed_warnings = []
for setting_name, setting_value in settings_overrides.items(): for setting_name, setting_value in settings_overrides.items():
warnings.resetwarnings() warnings.resetwarnings()
@ -131,8 +104,8 @@ class DeprecatedAPITestCase(django.test.SimpleTestCase):
reload(django_downloadview.nginx.settings) reload(django_downloadview.nginx.settings)
caught = False caught = False
for warning_item in warning_list: for warning_item in warning_list:
if warning_item.category is DeprecationWarning: if warning_item.category == DeprecationWarning:
if "deprecated" in str(warning_item.message): if 'deprecated' in str(warning_item.message):
if setting_name in str(warning_item.message): if setting_name in str(warning_item.message):
caught = True caught = True
break break
@ -140,6 +113,5 @@ class DeprecatedAPITestCase(django.test.SimpleTestCase):
missed_warnings.append(setting_name) missed_warnings.append(setting_name)
if missed_warnings: if missed_warnings:
self.fail( self.fail(
f"No DeprecationWarning raised about following settings: " 'No DeprecationWarning raised about following settings: '
f"{', '.join(missed_warnings)}." '{settings}.'.format(settings=', '.join(missed_warnings)))
)

View file

@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
"""Unit tests around views."""
import unittest
try:
from unittest import mock
except ImportError:
import mock
from django.http.response import HttpResponseNotModified
import django.test
from django_downloadview.test import setup_view
from django_downloadview.views import base
class DownloadMixinTestCase(unittest.TestCase):
"""Tests around :class:`django_downloadviews.views.base.DownloadMixin`."""
def test_get_file(self):
"""DownloadMixin.get_file() raise NotImplementedError.
Subclasses must implement it!
"""
mixin = base.DownloadMixin()
with self.assertRaises(NotImplementedError):
mixin.get_file()
def test_get_basename(self):
"""DownloadMixin.get_basename() returns basename attribute."""
mixin = base.DownloadMixin()
self.assertEqual(mixin.get_basename(), None)
mixin.basename = 'fake'
self.assertEqual(mixin.get_basename(), 'fake')
def test_was_modified_since_file(self):
"""DownloadMixin.was_modified_since() tries (1) file's implementation.
:meth:`django_downloadview.views.base.DownloadMixin.was_modified_since`
first tries to delegate computations to file wrapper's implementation.
"""
file_wrapper = mock.Mock()
file_wrapper.was_modified_since = mock.Mock(
return_value=mock.sentinel.was_modified)
mixin = base.DownloadMixin()
self.assertIs(
mixin.was_modified_since(file_wrapper, mock.sentinel.since),
mock.sentinel.was_modified)
file_wrapper.was_modified_since.assertCalledOnceWith(
mock.sentinel.since)
def test_was_modified_since_django(self):
"""DownloadMixin.was_modified_since() tries (2) files attributes.
When calling file wrapper's ``was_modified_since()`` raises
``NotImplementedError`` or ``AttributeError``,
:meth:`django_downloadview.views.base.DownloadMixin.was_modified_since`
tries to pass file wrapper's ``size`` and ``modified_time`` to
:func:`django.views.static import was_modified_since`.
"""
file_wrapper = mock.Mock()
file_wrapper.was_modified_since = mock.Mock(
side_effect=AttributeError)
file_wrapper.size = mock.sentinel.size
file_wrapper.modified_time = mock.sentinel.modified_time
was_modified_since_mock = mock.Mock(
return_value=mock.sentinel.was_modified)
mixin = base.DownloadMixin()
with mock.patch('django_downloadview.views.base.was_modified_since',
new=was_modified_since_mock):
self.assertIs(
mixin.was_modified_since(file_wrapper, mock.sentinel.since),
mock.sentinel.was_modified)
was_modified_since_mock.assertCalledOnceWith(
mock.sentinel.size,
mock.sentinel.modified_time)
def test_was_modified_since_fallback(self):
"""DownloadMixin.was_modified_since() fallbacks to `True`.
When:
* calling file wrapper's ``was_modified_since()`` raises
``NotImplementedError`` or ``AttributeError``;
* and accessing ``size`` and ``modified_time`` from file wrapper raises
``NotImplementedError`` or ``AttributeError``...
... then
:meth:`django_downloadview.views.base.DownloadMixin.was_modified_since`
returns ``True``.
"""
file_wrapper = mock.Mock()
file_wrapper.was_modified_since = mock.Mock(
side_effect=NotImplementedError)
type(file_wrapper).modified_time = mock.PropertyMock(
side_effect=NotImplementedError)
mixin = base.DownloadMixin()
self.assertIs(
mixin.was_modified_since(file_wrapper, 'fake since'),
True)
def test_not_modified_response(self):
"DownloadMixin.not_modified_response returns HttpResponseNotModified."
mixin = base.DownloadMixin()
response = mixin.not_modified_response()
self.assertTrue(isinstance(response, HttpResponseNotModified))
def test_download_response(self):
"DownloadMixin.download_response() returns download response instance."
mixin = base.DownloadMixin()
mixin.file_instance = mock.sentinel.file_wrapper
response_factory = mock.Mock(return_value=mock.sentinel.response)
mixin.response_class = response_factory
response_kwargs = {'dummy': 'value',
'file_instance': mock.sentinel.file_wrapper,
'attachment': True,
'basename': None}
response = mixin.download_response(**response_kwargs)
self.assertIs(response, mock.sentinel.response)
response_factory.assert_called_once_with(**response_kwargs) # Not args
def test_render_to_response_not_modified(self):
"""DownloadMixin.render_to_response() respects HTTP_IF_MODIFIED_SINCE
header (calls ``not_modified_response()``)."""
# Setup.
mixin = base.DownloadMixin()
mixin.request = django.test.RequestFactory().get(
'/dummy-url',
HTTP_IF_MODIFIED_SINCE=mock.sentinel.http_if_modified_since)
mixin.was_modified_since = mock.Mock(return_value=False)
mixin.not_modified_response = mock.Mock(
return_value=mock.sentinel.http_not_modified_response)
mixin.get_file = mock.Mock(return_value=mock.sentinel.file_wrapper)
# Run.
response = mixin.render_to_response()
# Check.
self.assertIs(response, mock.sentinel.http_not_modified_response)
mixin.get_file.assert_called_once_with()
mixin.was_modified_since.assert_called_once_with(
mock.sentinel.file_wrapper,
mock.sentinel.http_if_modified_since)
mixin.not_modified_response.assert_called_once_with()
def test_render_to_response_modified(self):
"""DownloadMixin.render_to_response() calls download_response()."""
# Setup.
mixin = base.DownloadMixin()
mixin.request = django.test.RequestFactory().get(
'/dummy-url',
HTTP_IF_MODIFIED_SINCE=None)
mixin.was_modified_since = mock.Mock()
mixin.download_response = mock.Mock(
return_value=mock.sentinel.download_response)
mixin.get_file = mock.Mock(return_value=mock.sentinel.file_wrapper)
# Run.
response = mixin.render_to_response()
# Check.
self.assertIs(response, mock.sentinel.download_response)
mixin.get_file.assert_called_once_with()
self.assertEqual(mixin.was_modified_since.call_count, 0)
mixin.download_response.assert_called_once_with()
class BaseDownloadViewTestCase(unittest.TestCase):
"Tests around :class:`django_downloadviews.views.base.BaseDownloadView`."
def test_get(self):
"""BaseDownloadView.get() calls render_to_response()."""
request = django.test.RequestFactory().get('/dummy-url')
args = ['dummy-arg']
kwargs = {'dummy': 'kwarg'}
view = setup_view(base.BaseDownloadView(), request, *args, **kwargs)
view.render_to_response = mock.Mock(
return_value=mock.sentinel.response)
response = view.get(request, *args, **kwargs)
self.assertIs(response, mock.sentinel.response)
view.render_to_response.assert_called_once_with()

View file

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
"""Utility functions that may be implemented in external packages.""" """Utility functions that may be implemented in external packages."""
import re import re
charset_pattern = re.compile(r"charset=(?P<charset>.+)$", re.I | re.U)
charset_pattern = re.compile(r'charset=(?P<charset>.+)$', re.I | re.U)
def content_type_to_charset(content_type): def content_type_to_charset(content_type):
@ -15,7 +16,7 @@ def content_type_to_charset(content_type):
""" """
match = re.search(charset_pattern, content_type) match = re.search(charset_pattern, content_type)
if match: if match:
return match.group("charset") return match.group('charset')
def url_basename(url, content_type): def url_basename(url, content_type):
@ -25,11 +26,11 @@ def url_basename(url, content_type):
If URL contains extension, it is kept as-is. If URL contains extension, it is kept as-is.
>>> print(url_basename(u'/path/to/somefile.rst', 'text/plain')) >>> url_basename(u'/path/to/somefile.rst', 'text/plain')
somefile.rst u'somefile.rst'
""" """
return url.split("/")[-1] return url.split('/')[-1]
def import_member(import_string): def import_member(import_string):
@ -41,6 +42,6 @@ def import_member(import_string):
True True
""" """
module_name, factory_name = str(import_string).rsplit(".", 1) module_name, factory_name = str(import_string).rsplit('.', 1)
module = __import__(module_name, globals(), locals(), [factory_name], 0) module = __import__(module_name, globals(), locals(), [factory_name], -1)
return getattr(module, factory_name) return getattr(module, factory_name)

View file

@ -1,9 +1,12 @@
# coding=utf-8
"""Views."""
# -*- coding: utf-8 -*-
"""Views to stream files.""" """Views to stream files."""
# API shortcuts. # API shortcuts.
from django_downloadview.views.base import BaseDownloadView, DownloadMixin # NoQA from django_downloadview.views.base import (DownloadMixin, # NoQA
from django_downloadview.views.http import HTTPDownloadView # NoQA BaseDownloadView)
from django_downloadview.views.object import ObjectDownloadView # NoQA
from django_downloadview.views.path import PathDownloadView # NoQA from django_downloadview.views.path import PathDownloadView # NoQA
from django_downloadview.views.storage import StorageDownloadView # NoQA from django_downloadview.views.storage import StorageDownloadView # NoQA
from django_downloadview.views.object import ObjectDownloadView # NoQA
from django_downloadview.views.http import HTTPDownloadView # NoQA
from django_downloadview.views.virtual import VirtualDownloadView # NoQA from django_downloadview.views.virtual import VirtualDownloadView # NoQA

View file

@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
"""Base material for download views: :class:`DownloadMixin` and """Base material for download views: :class:`DownloadMixin` and
:class:`BaseDownloadView`""" :class:`BaseDownloadView`"""
from django.http import HttpResponseNotModified
import calendar
from django.http import Http404, HttpResponseNotModified
from django.views.generic.base import View from django.views.generic.base import View
from django.views.static import was_modified_since from django.views.static import was_modified_since
from django_downloadview import exceptions
from django_downloadview.response import DownloadResponse from django_downloadview.response import DownloadResponse
@ -26,74 +23,22 @@ class DownloadMixin(object):
returned by :py:meth:`get_file`. returned by :py:meth:`get_file`.
""" """
#: Response class, to be used in :py:meth:`render_to_response`. #: Response class, to be used in :py:meth:`render_to_response`.
response_class = DownloadResponse response_class = DownloadResponse
#: Whether to return the response as attachment or not. #: Whether to return the response as attachment or not.
#:
#: When ``True`` (the default), the view returns file "as attachment",
#: which usually triggers a "Save the file as ..." prompt.
#:
#: When ``False``, the view returns file "inline", as if it was an element
#: of the current page.
#:
#: .. note::
#:
#: The actual behaviour client-side depends on the browser and its
#: configuration.
#:
#: In fact, affects the "Content-Disposition" header via :attr:`response's
#: attachment attribute
#: <django_downloadview.response.DownloadResponse.attachment>`.
attachment = True attachment = True
#: Client-side filename, if only file is returned as attachment. #: Client-side filename, if only file is returned as attachment.
basename = None basename = None
#: File's mime type.
#: If ``None`` (the default), then the file's mime type will be guessed via
#: :mod:`mimetypes`.
mimetype = None
#: File's encoding.
#: If ``None`` (the default), then the file's encoding will be guessed via
#: :mod:`mimetypes`.
encoding = None
def get_file(self): def get_file(self):
"""Return a file wrapper instance. """Return a file wrapper instance."""
Raises :class:`~django_downloadview.exceptions.FileNotFound` if file
does not exist.
"""
raise NotImplementedError() raise NotImplementedError()
def get_basename(self): def get_basename(self):
"""Return :attr:`basename`.
Override this method if you need more dynamic basename.
"""
return self.basename return self.basename
def get_mimetype(self):
"""Return :attr:`mimetype`.
Override this method if you need more dynamic mime type.
"""
return self.mimetype
def get_encoding(self):
"""Return :attr:`encoding`.
Override this method if you need more dynamic encoding.
"""
return self.encoding
def was_modified_since(self, file_instance, since): def was_modified_since(self, file_instance, since):
"""Return True if ``file_instance`` was modified after ``since``. """Return True if ``file_instance`` was modified after ``since``.
@ -103,9 +48,9 @@ class DownloadMixin(object):
Else, fallbacks to default implementation, which uses Else, fallbacks to default implementation, which uses
:py:func:`django.views.static.was_modified_since`. :py:func:`django.views.static.was_modified_since`.
Django's ``was_modified_since`` function needs a datetime. Django's ``was_modified_since`` function needs a datetime and a size.
It is passed the ``modified_time`` attribute from file It is passed ``modified_time`` and ``size`` attributes from file
wrapper. If file wrapper does not support this attribute wrapper. If file wrapper does not support these attributes
(``AttributeError`` or ``NotImplementedError`` is raised), then (``AttributeError`` or ``NotImplementedError`` is raised), then
the file is considered as modified and ``True`` is returned. the file is considered as modified and ``True`` is returned.
@ -114,14 +59,12 @@ class DownloadMixin(object):
return file_instance.was_modified_since(since) return file_instance.was_modified_since(since)
except (AttributeError, NotImplementedError): except (AttributeError, NotImplementedError):
try: try:
modification_time = calendar.timegm( modification_time = file_instance.modified_time
file_instance.modified_time.utctimetuple() size = file_instance.size
) except (AttributeError, NotImplementedError):
except (AttributeError, NotImplementedError) as e:
print("!=======!", e)
return True return True
else: else:
return was_modified_since(since, modification_time) return was_modified_since(since, modification_time, size)
def not_modified_response(self, *response_args, **response_kwargs): def not_modified_response(self, *response_args, **response_kwargs):
"""Return :class:`django.http.HttpResponseNotModified` instance.""" """Return :class:`django.http.HttpResponseNotModified` instance."""
@ -129,22 +72,14 @@ class DownloadMixin(object):
def download_response(self, *response_args, **response_kwargs): def download_response(self, *response_args, **response_kwargs):
"""Return :class:`~django_downloadview.response.DownloadResponse`.""" """Return :class:`~django_downloadview.response.DownloadResponse`."""
response_kwargs.setdefault("file_instance", self.file_instance) response_kwargs.setdefault('file_instance', self.file_instance)
response_kwargs.setdefault("attachment", self.attachment) response_kwargs.setdefault('attachment', self.attachment)
response_kwargs.setdefault("basename", self.get_basename()) response_kwargs.setdefault('basename', self.get_basename())
response_kwargs.setdefault("file_mimetype", self.get_mimetype())
response_kwargs.setdefault("file_encoding", self.get_encoding())
response = self.response_class(*response_args, **response_kwargs) response = self.response_class(*response_args, **response_kwargs)
return response return response
def file_not_found_response(self):
"""Raise Http404."""
raise Http404()
def render_to_response(self, *response_args, **response_kwargs): def render_to_response(self, *response_args, **response_kwargs):
"""Return "download" response (if everything is ok). """Return "download" response.
Return :meth:`file_not_found_response` if file does not exist.
Respects the "HTTP_IF_MODIFIED_SINCE" header if any. In that case, uses Respects the "HTTP_IF_MODIFIED_SINCE" header if any. In that case, uses
:py:meth:`was_modified_since` and :py:meth:`not_modified_response`. :py:meth:`was_modified_since` and :py:meth:`not_modified_response`.
@ -152,12 +87,9 @@ class DownloadMixin(object):
Else, uses :py:meth:`download_response` to return a download response. Else, uses :py:meth:`download_response` to return a download response.
""" """
try: self.file_instance = self.get_file()
self.file_instance = self.get_file()
except exceptions.FileNotFound:
return self.file_not_found_response()
# Respect the If-Modified-Since header. # Respect the If-Modified-Since header.
since = self.request.headers.get("if-modified-since", None) since = self.request.META.get('HTTP_IF_MODIFIED_SINCE', None)
if since is not None: if since is not None:
if not self.was_modified_since(self.file_instance, since): if not self.was_modified_since(self.file_instance, since):
return self.not_modified_response(**response_kwargs) return self.not_modified_response(**response_kwargs)
@ -167,7 +99,6 @@ class DownloadMixin(object):
class BaseDownloadView(DownloadMixin, View): class BaseDownloadView(DownloadMixin, View):
"""A base :class:`DownloadMixin` that implements :meth:`get`.""" """A base :class:`DownloadMixin` that implements :meth:`get`."""
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Handle GET requests: stream a file.""" """Handle GET requests: stream a file."""
return self.render_to_response() return self.render_to_response()

View file

@ -1,16 +1,15 @@
# -*- coding: utf-8 -*-
"""Stream files given an URL, i.e. files you want to proxy.""" """Stream files given an URL, i.e. files you want to proxy."""
import requests
from django_downloadview.files import HTTPFile from django_downloadview.files import HTTPFile
from django_downloadview.views.base import BaseDownloadView from django_downloadview.views.base import BaseDownloadView
import requests
class HTTPDownloadView(BaseDownloadView): class HTTPDownloadView(BaseDownloadView):
"""Proxy files that live on remote servers.""" """Proxy files that live on remote servers."""
#: URL to download (the one we are proxying). #: URL to download (the one we are proxying).
url = "" url = u''
#: Additional keyword arguments for request handler. #: Additional keyword arguments for request handler.
request_kwargs = {} request_kwargs = {}
@ -41,9 +40,7 @@ class HTTPDownloadView(BaseDownloadView):
def get_file(self): def get_file(self):
"""Return wrapper which has an ``url`` attribute.""" """Return wrapper which has an ``url`` attribute."""
return HTTPFile( return HTTPFile(request_factory=self.get_request_factory(),
request_factory=self.get_request_factory(), name=self.get_basename(),
name=self.get_basename(), url=self.get_url(),
url=self.get_url(), **self.get_request_kwargs())
**self.get_request_kwargs(),
)

View file

@ -1,40 +1,31 @@
# -*- coding: utf-8 -*-
"""Stream files that live in models.""" """Stream files that live in models."""
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django_downloadview.exceptions import FileNotFound
from django_downloadview.views.base import BaseDownloadView from django_downloadview.views.base import BaseDownloadView
class ObjectDownloadView(SingleObjectMixin, BaseDownloadView): class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
"""Serve file fields from models. """Serve file fields from models.
This class extends :class:`~django.views.generic.detail.SingleObjectMixin`, This class extends BaseDetailView, so you can use its arguments to target
so you can use its arguments to target the instance to operate on: the instance to operate on: slug, slug_kwarg, model, queryset...
``slug``, ``slug_kwarg``, ``model``, ``queryset``... See Django's DetailView reference for details.
In addition to :class:`~django.views.generic.detail.SingleObjectMixin` In addition to BaseDetailView arguments, you can set arguments related to
arguments, you can set arguments related to the file to be downloaded: the file to be downloaded.
* :attr:`file_field`; The main one is ``file_field``.
* :attr:`basename_field`;
* :attr:`encoding_field`;
* :attr:`mime_type_field`;
* :attr:`charset_field`;
* :attr:`modification_time_field`;
* :attr:`size_field`.
:attr:`file_field` is the main one. Other arguments are provided for The other arguments are provided for convenience, in case your model holds
convenience, in case your model holds some (deserialized) metadata about some (deserialized) metadata about the file, such as its basename, its
the file, such as its basename, its modification time, its MIME type... modification time, its MIME type... These fields may be particularly handy
These fields may be particularly handy if your file storage is not the if your file storage is not the local filesystem.
local filesystem.
""" """
#: Name of the model's attribute which contains the file to be streamed. #: Name of the model's attribute which contains the file to be streamed.
#: Typically the name of a FileField. #: Typically the name of a FileField.
file_field = "file" file_field = 'file'
#: Optional name of the model's attribute which contains the basename. #: Optional name of the model's attribute which contains the basename.
basename_field = None basename_field = None
@ -62,21 +53,15 @@ class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
is typically a :class:`~django.db.models.fields.files.FieldFile` or is typically a :class:`~django.db.models.fields.files.FieldFile` or
subclass. subclass.
Raises :class:`~django_downloadview.exceptions.FileNotFound` if
instance's field is empty.
Additional attributes are set on the file wrapper if :attr:`encoding`, Additional attributes are set on the file wrapper if :attr:`encoding`,
:attr:`mime_type`, :attr:`charset`, :attr:`modification_time` or :attr:`mime_type`, :attr:`charset`, :attr:`modification_time` or
:attr:`size` are configured. :attr:`size` are configured.
""" """
file_instance = getattr(self.object, self.file_field) file_instance = getattr(self.object, self.file_field)
if not file_instance: for field in ('encoding', 'mime_type', 'charset', 'modification_time',
raise FileNotFound( 'size'):
f'Field="{self.file_field}" on object="{self.object}" is empty' model_field = getattr(self, '%s_field' % field, False)
)
for field in ("encoding", "mime_type", "charset", "modification_time", "size"):
model_field = getattr(self, "%s_field" % field, False)
if model_field: if model_field:
value = getattr(self.object, model_field) value = getattr(self.object, model_field)
setattr(file_instance, field, value) setattr(file_instance, field, value)
@ -84,14 +69,14 @@ class ObjectDownloadView(SingleObjectMixin, BaseDownloadView):
def get_basename(self): def get_basename(self):
"""Return client-side filename.""" """Return client-side filename."""
basename = super().get_basename() basename = super(ObjectDownloadView, self).get_basename()
if basename is None: if basename is None:
field = "basename" field = 'basename'
model_field = getattr(self, "%s_field" % field, False) model_field = getattr(self, '%s_field' % field, False)
if model_field: if model_field:
basename = getattr(self.object, model_field) basename = getattr(self.object, model_field)
return basename return basename
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
return super().get(request, *args, **kwargs) return super(ObjectDownloadView, self).get(request, *args, **kwargs)

View file

@ -1,16 +1,12 @@
# -*- coding: utf-8 -*-
""":class:`PathDownloadView`.""" """:class:`PathDownloadView`."""
import os
from django.core.files import File from django.core.files import File
from django_downloadview.exceptions import FileNotFound
from django_downloadview.views.base import BaseDownloadView from django_downloadview.views.base import BaseDownloadView
class PathDownloadView(BaseDownloadView): class PathDownloadView(BaseDownloadView):
"""Serve a file using filename.""" """Serve a file using filename."""
#: Server-side name (including path) of the file to serve. #: Server-side name (including path) of the file to serve.
#: #:
#: Filename is supposed to be an absolute filename of a file located on the #: Filename is supposed to be an absolute filename of a file located on the
@ -18,7 +14,7 @@ class PathDownloadView(BaseDownloadView):
path = None path = None
#: Name of the URL argument that contains path. #: Name of the URL argument that contains path.
path_url_kwarg = "path" path_url_kwarg = 'path'
def get_path(self): def get_path(self):
"""Return actual path of the file to serve. """Return actual path of the file to serve.
@ -34,7 +30,4 @@ class PathDownloadView(BaseDownloadView):
def get_file(self): def get_file(self):
"""Use path to return wrapper around file to serve.""" """Use path to return wrapper around file to serve."""
filename = self.get_path() return File(open(self.get_path(), 'rb'))
if not os.path.isfile(filename):
raise FileNotFound(f'File "{filename}" does not exists')
return File(open(filename, "rb"))

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Stream files from storage.""" """Stream files from storage."""
from django.core.files.storage import DefaultStorage from django.core.files.storage import DefaultStorage
from django_downloadview.files import StorageFile from django_downloadview.files import StorageFile
@ -8,13 +8,22 @@ from django_downloadview.views.path import PathDownloadView
class StorageDownloadView(PathDownloadView): class StorageDownloadView(PathDownloadView):
"""Serve a file using storage and filename.""" """Serve a file using storage and filename."""
#: Storage the file to serve belongs to. #: Storage the file to serve belongs to.
storage = DefaultStorage() storage = DefaultStorage()
#: Path to the file to serve relative to storage. #: Path to the file to serve relative to storage.
path = None # Override docstring. path = None # Override docstring.
def get_path(self):
"""Return path of the file to serve, relative to storage.
Default implementation simply returns view's :py:attr:`path` attribute.
Override this method if you want custom implementation.
"""
return super(StorageDownloadView, self).get_path()
def get_file(self): def get_file(self):
"""Return :class:`~django_downloadview.files.StorageFile` instance.""" """Return :class:`~django_downloadview.files.StorageFile` instance."""
return StorageFile(self.storage, self.get_path()) return StorageFile(self.storage, self.get_path())

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Stream files that you generate or that live in memory.""" """Stream files that you generate or that live in memory."""
from django_downloadview.views.base import BaseDownloadView from django_downloadview.views.base import BaseDownloadView
@ -9,7 +9,6 @@ class VirtualDownloadView(BaseDownloadView):
Override the :py:meth:`get_file` method to customize file wrapper. Override the :py:meth:`get_file` method to customize file wrapper.
""" """
def was_modified_since(self, file_instance, since): def was_modified_since(self, file_instance, since):
"""Delegate to file wrapper's was_modified_since, or return True. """Delegate to file wrapper's was_modified_since, or return True.

View file

@ -3,7 +3,7 @@
# You can set these variables from the command line. # You can set these variables from the command line.
SPHINXOPTS = SPHINXOPTS =
SPHINXBUILD = sphinx-build SPHINXBUILD = ../bin/sphinx-build
PAPER = PAPER =
BUILDDIR = ../var/docs BUILDDIR = ../var/docs

View file

@ -5,22 +5,22 @@ Alternatives and related projects
This document presents other projects that provide similar or complementary This document presents other projects that provide similar or complementary
functionalities. It focuses on differences with django-downloadview. functionalities. It focuses on differences with django-downloadview.
There is a comparison grid on djangopackages.com:
https://www.djangopackages.com/grids/g/file-streaming/.
Here are additional highlights...
************************* *************************
Django's static file view Django's static file view
************************* *************************
`django.contrib.staticfiles provides a view to serve files`_. It is simple and `Django has a builtin static file view`_. It can stream files. As explained in
quite naive by design: it is meant for development, not for production. Django documentation, it is designed for development purposes. For production,
See `Django ticket #2131`_: advanced file streaming is left to third-party static files'd better be served by some optimized server.
applications.
`django-downloadview` is such a third-party application. Django-downloadview can replace Django's builtin static file view:
* perform actions with Django when receiving download requests: check
permissions, generate files, gzip, logging, signals...
* delegate actual download to a reverse proxy for increased performance.
* disable optimization middlewares or decorators in development, if you want to
serve files with Django.
*************** ***************
@ -30,11 +30,6 @@ django-sendfile
`django-sendfile`_ is a wrapper around web-server specific methods for sending `django-sendfile`_ is a wrapper around web-server specific methods for sending
files to web clients. files to web clients.
.. note::
:func:`django_downloadview.shortcuts.sendfile` is a port of
`django-sendfile`'s main function. See :doc:`/django-sendfile` for details.
``django-senfile``'s main focus is simplicity: API is made of a single ``django-senfile``'s main focus is simplicity: API is made of a single
``sendfile()`` function you call inside your views: ``sendfile()`` function you call inside your views:
@ -69,12 +64,40 @@ Here are main differences between the two projects:
root folder. Whereas ``django-downloadview``'s root folder. Whereas ``django-downloadview``'s
``DownloadDispatcherMiddleware`` supports multiple configurations. ``DownloadDispatcherMiddleware`` supports multiple configurations.
As of 2012-04-11, ``django-sendfile`` (version 0.3.2) seems quite popular and
may be a good alternative **provided you serve files that live in a single
directory of local filesystem**.
:func:`django_downloadview.sendfile` is a port of django-sendfile's main function.
********************
django-private-files
********************
`django-private-files`_ provides utilities for controlling access to static
files based on conditions you can specify within your Django application.
**********************
django-protected-files
**********************
`django-protected-files`_ is a Django application that lets you serve protected
static files via your frontend server after authorizing the user against
``django.contrib.auth``.
As of 2012-12-10, this project seems inactive.
.. rubric:: References .. rubric:: References
.. target-notes:: .. target-notes::
.. _`django.contrib.staticfiles provides a view to serve files`: .. _`Django has a builtin static file view`:
https://docs.djangoproject.com/en/3.0/ref/contrib/staticfiles/#static-file-development-view https://docs.djangoproject.com/en/1.4/ref/contrib/staticfiles/#static-file-development-view
.. _`Django ticket #2131`: https://code.djangoproject.com/ticket/2131
.. _`django-sendfile`: http://pypi.python.org/pypi/django-sendfile .. _`django-sendfile`: http://pypi.python.org/pypi/django-sendfile
.. _`requests`: https://pypi.python.org/pypi/requests
.. _`django-private-files`: http://pypi.python.org/pypi/django-private-files
.. _`django-protected-files`:
https://github.com/lincolnloop/django-protected-files

View file

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

View file

@ -1,142 +1,275 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""django-downloadview documentation build configuration file.""" #
# 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.
import os
import re
import importlib.metadata
# Minimal Django settings. Required to use sphinx.ext.autodoc, because # Minimal Django settings. Required to use sphinx.ext.autodoc, because
# django-downloadview depends on Django... # django-downloadview depends on Django...
from django.conf import settings from django.conf import settings
settings.configure( settings.configure(
DATABASES={}, # Required to load ``django.views.generic``. DATABASES={}, # Required to load ``django.views.generic``.
) )
# -- General configuration ---------------------------------------------------- doc_dir = os.path.dirname(os.path.abspath(__file__))
project_dir = os.path.dirname(doc_dir)
version_filename = os.path.join(project_dir, 'VERSION')
# Extensions.
extensions = [ # If extensions (or modules to document with autodoc) are in another directory,
"sphinx.ext.autodoc", # add these directories to sys.path here. If the directory is relative to the
"sphinx.ext.autosummary", # documentation root, use os.path.abspath to make it absolute, like shown here.
"sphinx.ext.doctest", #sys.path.insert(0, os.path.abspath('.'))
"sphinx.ext.coverage",
"sphinx.ext.intersphinx", # -- 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']
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"] templates_path = ['_templates']
# The suffix of source filenames. # The suffix of source filenames.
source_suffix = ".txt" source_suffix = '.txt'
# The encoding of source files. # The encoding of source files.
source_encoding = "utf-8" #source_encoding = 'utf-8-sig'
# The master toctree document. # The master toctree document.
master_doc = "index" master_doc = 'index'
# General information about the project. # General information about the project.
project = "django-downloadview" project = u'django-downloadview'
project_slug = re.sub(r"([\w_.-]+)", "-", project) copyright = u'2012, Benoît Bryon'
copyright = "2012-2015, Benoît Bryon"
author = "Benoît Bryon"
author_slug = re.sub(r"([\w_.-]+)", "-", author)
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
# built documents. # built documents.
#
# The full version, including alpha/beta/rc tags.
release = importlib.metadata.version("django-downloadview")
# The short X.Y version. # The short X.Y version.
version = ".".join(release.split(".")[:2]) version = open(version_filename).read().strip()
# The full version, including alpha/beta/rc tags.
release = version
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.
language = "en" 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 # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # directories to ignore when looking for source files.
exclude_patterns = ["_build"] 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. # The name of the Pygments (syntax highlighting) style to use.
pygments_style = "sphinx" 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 # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # a list of builtin themes.
html_theme = "alabaster" 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
# Add any paths that contain custom static files (such as style sheets) here, # 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, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = [] html_static_path = ['_static']
# 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. # Custom sidebar templates, maps document names to template names.
html_sidebars = { html_sidebars = {
"**": ["globaltoc.html", "relations.html", "sourcelink.html", "searchbox.html"], '**': ['globaltoc.html',
'relations.html',
'sourcelink.html',
'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. # Output file base name for HTML help builder.
htmlhelp_basename = "{project}doc".format(project=project_slug) htmlhelp_basename = 'django-downloadviewdoc'
# -- Options for sphinx.ext.intersphinx --------------------------------------- # -- Options for sphinx.ext.intersphinx ---------------------------------------
intersphinx_mapping = { intersphinx_mapping = {
"python": ("https://docs.python.org/3", None), 'python': ('http://docs.python.org/2.7', None),
"django": ( 'django': ('http://docs.djangoproject.com/en/1.5/',
"https://docs.djangoproject.com/en/3.1/", 'http://docs.djangoproject.com/en/1.5/_objects/'),
"https://docs.djangoproject.com/en/3.1/_objects/", 'requests': ('http://docs.python-requests.org/en/latest/', None),
),
"requests": ("https://requests.readthedocs.io/en/master/", None),
} }
# -- Options for LaTeX output --------------------------------------------------
# -- Options for LaTeX output ------------------------------------------------- latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
latex_elements = {} # The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples # Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass # (source start file, target name, title, author, documentclass [howto/manual]).
# [howto/manual]).
latex_documents = [ latex_documents = [
( ('index', 'django-downloadview.tex', u'django-downloadview Documentation',
"index", u'Benoît Bryon', 'manual'),
"{project}.tex".format(project=project_slug),
"{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
# -- Options for manual page output ------------------------------------------- # 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 --------------------------------------------
# One entry per manual page. List of tuples # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (source start file, name, description, authors, manual section).
man_pages = [ man_pages = [
("index", project, "{project} Documentation".format(project=project), [author], 1) ('index', 'django-downloadview', u'django-downloadview Documentation',
[u'Benoît Bryon'], 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 # Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ texinfo_documents = [
( ('index', 'django-downloadview', u'django-downloadview Documentation',
"index", u'Benoît Bryon', 'django-downloadview', 'One line description of project.',
project_slug, 'Miscellaneous'),
"{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'

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