From 2d87e9bc37ef1ce8f9068fac92783a4fe3d3d382 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 10 May 2010 11:27:42 +0200 Subject: [PATCH] Added support for send_file --- CHANGES | 1 + docs/api.rst | 2 ++ flask.py | 75 +++++++++++++++++++++++++++++++++++++- tests/flask_tests.py | 85 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 161 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index c3489d27..b2f54fdc 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,7 @@ Version 0.2 view function. - server listens on 127.0.0.1 by default now to fix issues with chrome. - added external URL support. +- added support for :func:`~flask.send_file` Version 0.1 ----------- diff --git a/docs/api.rst b/docs/api.rst index ac761565..3e46dde4 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -213,6 +213,8 @@ Useful Functions and Classes .. autofunction:: redirect +.. autofunction:: send_file + .. autofunction:: escape .. autoclass:: Markup diff --git a/flask.py b/flask.py index 28becb3f..3d1a8b7b 100644 --- a/flask.py +++ b/flask.py @@ -12,12 +12,13 @@ from __future__ import with_statement import os import sys +import mimetypes from datetime import datetime, timedelta from jinja2 import Environment, PackageLoader, FileSystemLoader from werkzeug import Request as RequestBase, Response as ResponseBase, \ LocalStack, LocalProxy, create_environ, SharedDataMiddleware, \ - ImmutableDict, cached_property + ImmutableDict, cached_property, wrap_file, Headers from werkzeug.routing import Map, Rule from werkzeug.exceptions import HTTPException from werkzeug.contrib.securecookie import SecureCookie @@ -235,6 +236,71 @@ def jsonify(*args, **kwargs): indent=None if request.is_xhr else 2), mimetype='application/json') +def send_file(filename_or_fp, mimetype=None, as_attachment=False, + attachment_filename=None): + """Sends the contents of a file to the client. This will use the + most efficient method available and configured. By default it will + try to use the WSGI server's file_wrapper support. Alternatively + you can set the application's :attr:`~Flask.use_x_sendfile` attribute + to ``True`` to directly emit an `X-Sendfile` header. This however + requires support of the underlying webserver for `X-Sendfile`. + + By default it will try to guess the mimetype for you, but you can + also explicitly provide one. For extra security you probably want + to sent certain files as attachment (HTML for instance). + + .. versionadded:: 0.2 + + :param filename_or_fp: the filename of the file to send. This is + relative to the :attr:`~Flask.root_path` if a + relative path is specified. + Alternatively a file object might be provided + in which case `X-Sendfile` might not work and + fall back to the traditional method. + :param mimetype: the mimetype of the file if provided, otherwise + auto detection happens. + :param as_attachment: set to `True` if you want to send this file with + a ``Content-Disposition: attachment`` header. + :param attachment_filename: the filename for the attachment if it + differs from the file's filename. + """ + if isinstance(filename_or_fp, basestring): + filename = filename_or_fp + file = None + else: + file = filename_or_fp + filename = getattr(file, 'name', None) + if filename is not None: + filename = os.path.join(current_app.root_path, filename) + if mimetype is None and (filename or attachment_filename): + mimetype = mimetypes.guess_type(filename or attachment_filename)[0] + if mimetype is None: + mimetype = 'application/octet-stream' + + headers = Headers() + if as_attachment: + if attachment_filename is None: + if filename is None: + raise TypeError('filename unavailable, required for ' + 'sending as attachment') + attachment_filename = os.path.basename(filename) + headers.add('Content-Disposition', 'attachment', + filename=attachment_filename) + + if current_app.use_x_sendfile and filename: + if file is not None: + file.close() + headers['X-Sendfile'] = filename + data = None + else: + if file is None: + file = open(filename, 'rb') + data = wrap_file(request.environ, file) + + return Response(data, mimetype=mimetype, headers=headers, + direct_passthrough=True) + + def render_template(template_name, **context): """Renders a template from the template folder with the given context. @@ -344,6 +410,13 @@ class Flask(object): #: permanent session survive for roughly one month. permanent_session_lifetime = timedelta(days=31) + #: Enable this if you want to use the X-Sendfile feature. Keep in + #: mind that the server has to support this. This only affects files + #: sent with the :func:`send_file` method. + #: + #: .. versionadded:: 0.2 + use_x_sendfile = False + #: options that are passed directly to the Jinja2 environment jinja_options = ImmutableDict( autoescape=True, diff --git a/tests/flask_tests.py b/tests/flask_tests.py index 1daf0d4b..29ae2762 100644 --- a/tests/flask_tests.py +++ b/tests/flask_tests.py @@ -17,7 +17,8 @@ import flask import unittest import tempfile from datetime import datetime -from werkzeug import parse_date +from werkzeug import parse_date, parse_options_header +from cStringIO import StringIO example_path = os.path.join(os.path.dirname(__file__), '..', 'examples') @@ -382,6 +383,87 @@ class TemplatingTestCase(unittest.TestCase): assert rv.data == 'dcba' +class SendfileTestCase(unittest.TestCase): + + def test_send_file_regular(self): + app = flask.Flask(__name__) + with app.test_request_context(): + rv = flask.send_file('static/index.html') + assert rv.direct_passthrough + assert rv.mimetype == 'text/html' + with app.open_resource('static/index.html') as f: + assert rv.data == f.read() + + def test_send_file_xsendfile(self): + app = flask.Flask(__name__) + app.use_x_sendfile = True + with app.test_request_context(): + rv = flask.send_file('static/index.html') + assert rv.direct_passthrough + assert 'x-sendfile' in rv.headers + assert rv.headers['x-sendfile'] == \ + os.path.join(app.root_path, 'static/index.html') + assert rv.mimetype == 'text/html' + + def test_send_file_object(self): + app = flask.Flask(__name__) + with app.test_request_context(): + f = open(os.path.join(app.root_path, 'static/index.html')) + rv = flask.send_file(f) + with app.open_resource('static/index.html') as f: + assert rv.data == f.read() + assert rv.mimetype == 'text/html' + + app.use_x_sendfile = True + with app.test_request_context(): + f = open(os.path.join(app.root_path, 'static/index.html')) + rv = flask.send_file(f) + assert rv.mimetype == 'text/html' + assert 'x-sendfile' in rv.headers + assert rv.headers['x-sendfile'] == \ + os.path.join(app.root_path, 'static/index.html') + + app.use_x_sendfile = False + with app.test_request_context(): + f = StringIO('Test') + rv = flask.send_file(f) + assert rv.data == 'Test' + assert rv.mimetype == 'application/octet-stream' + f = StringIO('Test') + rv = flask.send_file(f, mimetype='text/plain') + assert rv.data == 'Test' + assert rv.mimetype == 'text/plain' + + app.use_x_sendfile = True + with app.test_request_context(): + f = StringIO('Test') + rv = flask.send_file(f) + assert 'x-sendfile' not in rv.headers + + def test_attachment(self): + app = flask.Flask(__name__) + with app.test_request_context(): + f = open(os.path.join(app.root_path, 'static/index.html')) + rv = flask.send_file(f, as_attachment=True) + value, options = parse_options_header(rv.headers['Content-Disposition']) + assert value == 'attachment' + + with app.test_request_context(): + assert options['filename'] == 'index.html' + rv = flask.send_file('static/index.html', as_attachment=True) + value, options = parse_options_header(rv.headers['Content-Disposition']) + assert value == 'attachment' + assert options['filename'] == 'index.html' + + with app.test_request_context(): + rv = flask.send_file(StringIO('Test'), as_attachment=True, + attachment_filename='index.txt') + assert rv.mimetype == 'text/plain' + value, options = parse_options_header(rv.headers['Content-Disposition']) + assert value == 'attachment' + assert options['filename'] == 'index.txt' + + def suite(): from minitwit_tests import MiniTwitTestCase from flaskr_tests import FlaskrTestCase @@ -389,6 +471,7 @@ def suite(): suite.addTest(unittest.makeSuite(ContextTestCase)) suite.addTest(unittest.makeSuite(BasicFunctionalityTestCase)) suite.addTest(unittest.makeSuite(TemplatingTestCase)) + suite.addTest(unittest.makeSuite(SendfileTestCase)) if flask.json_available: suite.addTest(unittest.makeSuite(JSONTestCase)) suite.addTest(unittest.makeSuite(MiniTwitTestCase))