diff --git a/.gitignore b/.gitignore index 2267b36..eeec5cf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ /lib /build .Python +.tox +.idea diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ddeea21 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,77 @@ +sudo: false + +language: python + +cache: pip + +python: + - 2.7 + - 3.3 + - 3.4 + - 3.5 + - 3.6 + +env: + - DJANGO=1.4 + - DJANGO=1.5 + - DJANGO=1.6 + - DJANGO=1.7 + - DJANGO=1.8 + - DJANGO=1.9 + - DJANGO=1.10 + - DJANGO=1.11 + +install: + - pip install -q tox + +script: + - tox -e py${TRAVIS_PYTHON_VERSION//./}-django${DJANGO//./} + +matrix: + exclude: + - python: 3.3 + env: DJANGO=1.3 + - python: 3.3 + env: DJANGO=1.4 + - python: 3.3 + env: DJANGO=1.5 + - python: 3.3 + env: DJANGO=1.6 + - python: 3.3 + env: DJANGO=1.7 + - python: 3.3 + env: DJANGO=1.9 + - python: 3.3 + env: DJANGO=1.10 + - python: 3.3 + env: DJANGO=1.11 + - python: 3.4 + env: DJANGO=1.3 + - python: 3.4 + env: DJANGO=1.4 + - python: 3.4 + env: DJANGO=1.5 + - python: 3.4 + env: DJANGO=1.6 + - python: 3.4 + env: DJANGO=1.7 + - python: 3.5 + env: DJANGO=1.3 + - python: 3.5 + env: DJANGO=1.4 + - python: 3.5 + env: DJANGO=1.5 + - python: 3.5 + env: DJANGO=1.6 + - python: 3.5 + env: DJANGO=1.7 + - python: 3.6 + env: DJANGO=1.3 + - python: 3.6 + env: DJANGO=1.4 + - python: 3.6 + env: DJANGO=1.5 + - python: 3.6 + env: DJANGO=1.6 + - python: 3.6 + env: DJANGO=1.7 diff --git a/README.rst b/README.rst index 76ccf99..10a51c0 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,9 @@ Django Sendfile =============== +.. image:: https://img.shields.io/travis/mpasternak/django-sendfile.svg + :target: https://travis-ci.org/mpasternak/django-sendfile + This is a wrapper around web-server specific methods for sending files to web clients. This is useful when Django needs to check permissions associated files, but does not want to serve the actual bytes of the file itself. i.e. as serving large files is not what Django is made for. Note this should not be used for regular file serving (e.g. css etc), only for cases where you need Django to do some work before serving the actual file. diff --git a/examples/protected_downloads/download/models.py b/examples/protected_downloads/download/models.py index 0e10dbc..300f71e 100644 --- a/examples/protected_downloads/download/models.py +++ b/examples/protected_downloads/download/models.py @@ -23,3 +23,6 @@ def __unicode__(self): @models.permalink def get_absolute_url(self): return ('download', [self.pk], {}) + + class Meta: + app_label = 'download' diff --git a/examples/protected_downloads/download/urls.py b/examples/protected_downloads/download/urls.py index fd4bb28..a47e001 100644 --- a/examples/protected_downloads/download/urls.py +++ b/examples/protected_downloads/download/urls.py @@ -1,8 +1,18 @@ -from django.conf.urls.defaults import * - from .views import download, download_list -urlpatterns = patterns('', - url(r'^$', download_list), - url(r'(?P\d+)/$', download, name='download'), -) +import django + +if django.VERSION >= (1,9,0): + from django.conf.urls import * + urlpatterns = [ + url(r'^$', download_list), + url(r'(?P\d+)/$', download, name='download'), + ] +else: + from django.conf.urls.defaults import * + + urlpatterns = patterns( + '', + url(r'^$', download_list), + url(r'(?P\d+)/$', download, name='download'), + ) diff --git a/examples/protected_downloads/download/views.py b/examples/protected_downloads/download/views.py index e99b6bc..5495773 100644 --- a/examples/protected_downloads/download/views.py +++ b/examples/protected_downloads/download/views.py @@ -4,9 +4,9 @@ from django.db.models import Q from django.template import RequestContext -from sendfile import sendfile +from sendfile.core import sendfile -from .models import Download +from download.models import Download def download(request, download_id): diff --git a/examples/protected_downloads/manage.py b/examples/protected_downloads/manage.py index 3e098b0..8fb4aa8 100644 --- a/examples/protected_downloads/manage.py +++ b/examples/protected_downloads/manage.py @@ -1,14 +1,14 @@ #!/usr/bin/env python -from __future__ import absolute_import +# from __future__ import absolute_import -from django.core.management import execute_manager -try: - from . import settings # Assumed to be in the same directory. -except ImportError: - import sys - sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) - sys.exit(1) +#!/usr/bin/env python +import os +import sys + +sys.path = [ os.path.join(os.path.dirname(__file__), '..', '..'), ] + sys.path if __name__ == "__main__": - execute_manager(settings) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) diff --git a/examples/protected_downloads/urls.py b/examples/protected_downloads/urls.py index 238d8c7..788d752 100644 --- a/examples/protected_downloads/urls.py +++ b/examples/protected_downloads/urls.py @@ -1,9 +1,20 @@ -from django.conf.urls.defaults import * + from django.contrib import admin admin.autodiscover() -urlpatterns = patterns('', - (r'^', include('protected_downloads.download.urls')), - (r'^admin/', include(admin.site.urls)), -) +import django +if django.VERSION >= (1,9,0): + from django.conf.urls import * + urlpatterns = [ + url(r'^', include('protected_downloads.download.urls')), + url(r'^admin/', include(admin.site.urls)), + ] + +else: + from django.conf.urls.defaults import * + urlpatterns = patterns( + '', + (r'^', include('protected_downloads.download.urls')), + (r'^admin/', include(admin.site.urls)), + ) diff --git a/sendfile/__init__.py b/sendfile/__init__.py index 1cc9809..8ef6899 100644 --- a/sendfile/__init__.py +++ b/sendfile/__init__.py @@ -1,95 +1,3 @@ VERSION = (0, 3, 11) __version__ = '.'.join(map(str, VERSION)) -import os.path -from mimetypes import guess_type -import unicodedata - - -def _lazy_load(fn): - _cached = [] - def _decorated(): - if not _cached: - _cached.append(fn()) - return _cached[0] - def clear(): - while _cached: - _cached.pop() - _decorated.clear = clear - return _decorated - - -@_lazy_load -def _get_sendfile(): - try: - from importlib import import_module - except ImportError: - from django.utils.importlib import import_module - from django.conf import settings - from django.core.exceptions import ImproperlyConfigured - - backend = getattr(settings, 'SENDFILE_BACKEND', None) - if not backend: - raise ImproperlyConfigured('You must specify a value for SENDFILE_BACKEND') - module = import_module(backend) - return module.sendfile - - - -def sendfile(request, filename, attachment=False, attachment_filename=None, mimetype=None, encoding=None): - ''' - create a response to send file using backend configured in SENDFILE_BACKEND - - If attachment is True the content-disposition header will be set. - This will typically prompt the user to download the file, rather - than view it. The content-disposition filename depends on the - value of attachment_filename: - - None (default): Same as filename - False: No content-disposition filename - String: Value used as filename - - If no mimetype or encoding are specified, then they will be guessed via the - filename (using the standard python mimetypes module) - ''' - _sendfile = _get_sendfile() - - if not os.path.exists(filename): - from django.http import Http404 - raise Http404('"%s" does not exist' % filename) - - guessed_mimetype, guessed_encoding = guess_type(filename) - if mimetype is None: - if guessed_mimetype: - mimetype = guessed_mimetype - else: - mimetype = 'application/octet-stream' - - response = _sendfile(request, filename, mimetype=mimetype) - if attachment: - if attachment_filename is None: - attachment_filename = os.path.basename(filename) - parts = ['attachment'] - if attachment_filename: - try: - from django.utils.encoding import force_text - except ImportError: - # Django 1.3 - from django.utils.encoding import force_unicode as force_text - attachment_filename = force_text(attachment_filename) - ascii_filename = unicodedata.normalize('NFKD', attachment_filename).encode('ascii','ignore') - parts.append('filename="%s"' % ascii_filename) - if ascii_filename != attachment_filename: - from django.utils.http import urlquote - quoted_filename = urlquote(attachment_filename) - parts.append('filename*=UTF-8\'\'%s' % quoted_filename) - response['Content-Disposition'] = '; '.join(parts) - - response['Content-length'] = os.path.getsize(filename) - response['Content-Type'] = mimetype - if not encoding: - encoding = guessed_encoding - if encoding: - response['Content-Encoding'] = encoding - - return response diff --git a/sendfile/backends/xsendfile.py b/sendfile/backends/xsendfile.py index a87aa83..c7b0bb4 100644 --- a/sendfile/backends/xsendfile.py +++ b/sendfile/backends/xsendfile.py @@ -1,8 +1,12 @@ from django.http import HttpResponse +import six def sendfile(request, filename, **kwargs): response = HttpResponse() - response['X-Sendfile'] = unicode(filename).encode('utf-8') + if six.PY2: + response['X-Sendfile'] = unicode(filename).encode('utf-8') + else: + response['X-Sendfile'] = filename # .encode('utf-8') return response diff --git a/sendfile/core.py b/sendfile/core.py new file mode 100644 index 0000000..15389ca --- /dev/null +++ b/sendfile/core.py @@ -0,0 +1,103 @@ +# -*- encoding: utf-8 -*- + + +import os.path +from mimetypes import guess_type +import unicodedata + + +def _lazy_load(fn): + _cached = [] + + def _decorated(): + if not _cached: + _cached.append(fn()) + return _cached[0] + + def clear(): + while _cached: + _cached.pop() + + _decorated.clear = clear + return _decorated + + +@_lazy_load +def _get_sendfile(): + try: + from importlib import import_module + except ImportError: + from django.utils.importlib import import_module + from django.conf import settings + from django.core.exceptions import ImproperlyConfigured + + backend = getattr(settings, 'SENDFILE_BACKEND', None) + if not backend: + raise ImproperlyConfigured( + 'You must specify a value for SENDFILE_BACKEND') + module = import_module(backend) + return module.sendfile + + +def sendfile(request, filename, attachment=False, attachment_filename=None, + mimetype=None, encoding=None): + ''' + create a response to send file using backend configured in SENDFILE_BACKEND + + If attachment is True the content-disposition header will be set. + This will typically prompt the user to download the file, rather + than view it. The content-disposition filename depends on the + value of attachment_filename: + + None (default): Same as filename + False: No content-disposition filename + String: Value used as filename + + If no mimetype or encoding are specified, then they will be guessed via the + filename (using the standard python mimetypes module) + ''' + _sendfile = _get_sendfile() + + if not os.path.exists(filename): + from django.http import Http404 + raise Http404('"%s" does not exist' % filename) + + guessed_mimetype, guessed_encoding = guess_type(filename) + if mimetype is None: + if guessed_mimetype: + mimetype = guessed_mimetype + else: + mimetype = 'application/octet-stream' + + response = _sendfile(request, filename, mimetype=mimetype) + if attachment: + if attachment_filename is None: + attachment_filename = os.path.basename(filename) + parts = ['attachment'] + if attachment_filename: + try: + from django.utils.encoding import force_text + except ImportError: + # Django 1.3 + from django.utils.encoding import force_unicode as force_text + attachment_filename = force_text(attachment_filename) + ascii_filename = unicodedata.normalize('NFKD', attachment_filename) + ascii_filename = ascii_filename.encode('ascii', 'ignore') + import six + if six.PY3: + ascii_filename = ascii_filename.decode() + parts.append('filename="%s"' % ascii_filename) + if ascii_filename != attachment_filename: + from django.utils.http import urlquote + quoted_filename = urlquote(attachment_filename) + parts.append('filename*=UTF-8\'\'%s' % quoted_filename) + response['Content-Disposition'] = '; '.join(parts) + + response['Content-length'] = os.path.getsize(filename) + response['Content-Type'] = mimetype + if not encoding: + encoding = guessed_encoding + if encoding: + response['Content-Encoding'] = encoding + + return response \ No newline at end of file diff --git a/sendfile/tests.py b/sendfile/tests.py index 0643cae..e64a193 100644 --- a/sendfile/tests.py +++ b/sendfile/tests.py @@ -1,5 +1,9 @@ # coding=utf-8 +from __future__ import unicode_literals + +import six + from django.conf import settings from django.test import TestCase from django.http import HttpResponse, Http404, HttpRequest @@ -7,7 +11,7 @@ import os.path from tempfile import mkdtemp import shutil -from sendfile import sendfile as real_sendfile, _get_sendfile +from sendfile.core import sendfile as real_sendfile, _get_sendfile try: from urllib.parse import unquote @@ -107,7 +111,7 @@ def test_correct_file_in_xsendfile_header(self): self.assertEqual(filepath, response['X-Sendfile']) def test_xsendfile_header_containing_unicode(self): - filepath = self.ensure_file(u'péter_là_gueule.txt') + filepath = self.ensure_file('péter_là_gueule.txt') response = real_sendfile(HttpRequest(), filepath) self.assertTrue(response is not None) self.assertEqual(smart_str(filepath), response['X-Sendfile']) @@ -129,10 +133,14 @@ def test_correct_url_in_xaccelredirect_header(self): self.assertEqual('/private/readme.txt', response['X-Accel-Redirect']) def test_xaccelredirect_header_containing_unicode(self): - filepath = self.ensure_file(u'péter_là_gueule.txt') + filepath = self.ensure_file('péter_là_gueule.txt') response = real_sendfile(HttpRequest(), filepath) self.assertTrue(response is not None) - self.assertEqual(u'/private/péter_là_gueule.txt'.encode('utf-8'), unquote(response['X-Accel-Redirect'])) + s = '/private/péter_là_gueule.txt' + if six.PY2: + s = s.encode("utf-8") + self.assertEqual(s, unquote(response['X-Accel-Redirect'])) + class TestModWsgiBackend(TempFileTestCase): @@ -151,7 +159,10 @@ def test_correct_url_in_location_header(self): self.assertEqual('/private/readme.txt', response['Location']) def test_location_header_containing_unicode(self): - filepath = self.ensure_file(u'péter_là_gueule.txt') + filepath = self.ensure_file('péter_là_gueule.txt') response = real_sendfile(HttpRequest(), filepath) self.assertTrue(response is not None) - self.assertEqual(u'/private/péter_là_gueule.txt'.encode('utf-8'), unquote(response['Location'])) + s = '/private/péter_là_gueule.txt' + if six.PY2: + s = s.encode("utf-8") + self.assertEqual(s, unquote(response['Location'])) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..b9ad9cc --- /dev/null +++ b/tox.ini @@ -0,0 +1,79 @@ +[tox] +envlist = py{27,33,34,35,36}-django{14,15,16,17,18,19,110,111} + +[testenv] +usedevelop = True +deps = + six + django14: django>=1.4,<1.5 + django15: django>=1.5,<1.6 + django16: django>=1.6,<1.7 + django17: django>=1.7, <1.8 + django18: django>=1.8, <1.9 + django19: django>=1.9, <1.10 + django110: django>=1.10, <1.11 + django111: django>=1.11, <1.12 + +changedir = examples/protected_downloads + +commands = python manage.py test sendfile + +[testenv:py33-django13] +platform = nope + +[testenv:py33-django14] +platform = nope + +[testenv:py33-django15] +platform = nope + +[testenv:py33-django16] +platform = nope + +[testenv:py33-django17] +platform = nope + +[testenv:py34-django13] +platform = nope + +[testenv:py34-django14] +platform = nope + +[testenv:py34-django15] +platform = nope + +[testenv:py34-django16] +platform = nope + +[testenv:py34-django17] +platform = nope + +[testenv:py35-django13] +platform = nope + +[testenv:py35-django14] +platform = nope + +[testenv:py35-django15] +platform = nope + +[testenv:py35-django16] +platform = nope + +[testenv:py35-django17] +platform = nope + +[testenv:py36-django13] +platform = nope + +[testenv:py36-django14] +platform = nope + +[testenv:py36-django15] +platform = nope + +[testenv:py36-django16] +platform = nope + +[testenv:py36-django17] +platform = nope