# -*- coding: utf-8 -*- """:py:class:`django.http.HttpResponse` subclasses.""" import os import mimetypes import re import unicodedata import urllib from django.conf import settings from django.http import HttpResponse, StreamingHttpResponse from django.utils.encoding import force_str def encode_basename_ascii(value): """Return US-ASCII encoded ``value`` for use in Content-Disposition header. >>> encode_basename_ascii(unicode('éà', 'utf-8')) u'ea' Spaces are converted to underscores. >>> encode_basename_ascii(' ') u'_' Text with non US-ASCII characters is expected to be unicode. >>> encode_basename_ascii('éà') # doctest: +ELLIPSIS Traceback (most recent call last): ... UnicodeDecodeError: \'ascii\' codec can\'t decode byte ... Of course, ASCII values are not modified. >>> encode_basename_ascii('ea') u'ea' """ ascii_basename = unicode(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. >>> encode_basename_utf8(u' .txt') '%20.txt' >>> encode_basename_utf8(unicode('éà', 'utf-8')) '%C3%A9%C3%A0' """ return urllib.quote(force_str(value)) def content_disposition(filename): """Return value of ``Content-Disposition`` header with 'attachment'. >>> content_disposition('demo.txt') 'attachment; filename=demo.txt' If filename is empty, only "attachment" is returned. >>> content_disposition('') 'attachment' If filename contains non US-ASCII characters, the returned value contains UTF-8 encoded filename and US-ASCII fallback. >>> content_disposition(unicode('é.txt', 'utf-8')) "attachment; filename=e.txt; filename*=UTF-8''%C3%A9.txt" """ if not filename: return 'attachment' ascii_filename = encode_basename_ascii(filename) utf8_filename = encode_basename_utf8(filename) if ascii_filename == utf8_filename: # ASCII only. return "attachment; filename={ascii}".format(ascii=ascii_filename) else: return "attachment; filename={ascii}; filename*=UTF-8''{utf8}" \ .format(ascii=ascii_filename, utf8=utf8_filename) class DownloadResponse(StreamingHttpResponse): """File download response (Django serves file, client downloads it). This is a specialization of :class:`django.http.StreamingHttpResponse` where :attr:`~django.http.StreamingHttpResponse.streaming_content` is a file wrapper. Constructor differs a bit from :class:`~django.http.response.HttpResponse`: ``file_instance`` A :doc:`file wrapper instance `, 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). ``file_mimetype`` 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`. ``file_encoding`` 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`. Here are some highlights to understand internal mechanisms and motivations: * Let's start by quoting :pep:`3333` (WSGI specification): For large files, or for specialized uses of HTTP streaming, applications will usually return an iterator (often a generator-iterator) that produces the output in a block-by-block fashion. * Django WSGI handler (application implementation) returns response object (see :mod:`django.core.handlers.wsgi`). * :class:`django.http.HttpResponse` and subclasses are iterators. * In :class:`~django.http.StreamingHttpResponse`, the :meth:`~container.__iter__` implementation proxies to :attr:`~django.http.StreamingHttpResponse.streaming_content`. * In :class:`DownloadResponse` and subclasses, :attr:`streaming_content` is a :doc:`file wrapper `. File wrapper is itself an iterator over actual file content, and it also encapsulates access to file attributes (size, name, ...). """ def __init__(self, file_instance, attachment=True, basename=None, status=200, content_type=None, file_mimetype=None, file_encoding=None): """Constructor.""" self.file = file_instance super(DownloadResponse, self).__init__(streaming_content=self.file, status=status, content_type=content_type) self.basename = basename self.attachment = attachment if not content_type: del self['Content-Type'] # Will be set later. self.file_mimetype = file_mimetype self.file_encoding = file_encoding # Apply default headers. for header, value in self.default_headers.items(): if not header in self: self[header] = value # Does self support setdefault? @property def default_headers(self): """Return dictionary of automatically-computed headers. Uses an internal ``_default_headers`` cache. Default values are computed if only cache hasn't been set. ``Content-Disposition`` header is encoded according to `RFC 5987 `_. See also http://stackoverflow.com/questions/93551/. """ try: return self._default_headers except AttributeError: headers = {} headers['Content-Type'] = self.get_content_type() try: headers['Content-Length'] = self.file.size except (AttributeError, NotImplementedError): pass # Generated files. if self.attachment: basename = self.get_basename() headers['Content-Disposition'] = content_disposition(basename) self._default_headers = 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): """Return basename.""" if self.basename: return self.basename else: return os.path.basename(self.file.name) def get_content_type(self): """Return a suitable "Content-Type" header for ``self.file``.""" try: return self.file.content_type except AttributeError: 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): """Return mime-type of the file.""" if self.file_mimetype is not None: return self.file_mimetype default_mime_type = 'application/octet-stream' basename = self.get_basename() mime_type, encoding = mimetypes.guess_type(basename) return mime_type or default_mime_type def get_encoding(self): """Return encoding of the file to serve.""" if self.file_encoding is not None: return self.file_encoding basename = self.get_basename() mime_type, encoding = mimetypes.guess_type(basename) return encoding def get_charset(self): """Return the charset of the file to serve.""" return settings.DEFAULT_CHARSET class ProxiedDownloadResponse(HttpResponse): """Base class for internal redirect download responses. This base class makes it possible to identify several types of specific responses such as :py:class:`~django_downloadview.nginx.response.XAccelRedirectResponse`. """