From 25de45cbb6fc7304f67b685034151f9ac6ea0df5 Mon Sep 17 00:00:00 2001 From: Matt Robenolt Date: Thu, 3 Jan 2019 17:17:45 -0800 Subject: [PATCH] Add support for PathLike objects in static file helpers See: https://www.python.org/dev/peps/pep-0519/ This is mostly encountered with pathlib in python 3, but this API suggests any PathLike object can be treated like a filepath with `__fspath__` function. --- CHANGES.rst | 11 +++++++---- flask/_compat.py | 9 +++++++++ flask/helpers.py | 11 ++++++++++- tests/test_helpers.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 64892ae9..2a96fa9b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,14 +10,17 @@ Version 1.1 Unreleased - :meth:`flask.RequestContext.copy` includes the current session - object in the request context copy. This prevents ``flask.session`` + object in the request context copy. This prevents ``flask.session`` pointing to an out-of-date object. (`#2935`) - Using built-in RequestContext, unprintable Unicode characters in Host header will result in a HTTP 400 response and not HTTP 500 as previously. (`#2994`) +- :func:`send_file` supports ``PathLike`` objects as describe in + PEP 0519, to support ``pathlib`` in Python 3. (`#3059`_) .. _#2935: https://github.com/pallets/flask/issues/2935 .. _#2994: https://github.com/pallets/flask/pull/2994 +.. _#3059: https://github.com/pallets/flask/pull/3059 Version 1.0.3 @@ -355,7 +358,7 @@ Released on December 21st 2016, codename Punsch. - Add support for range requests in ``send_file``. - ``app.test_client`` includes preset default environment, which can now be directly set, instead of per ``client.get``. - + .. _#1849: https://github.com/pallets/flask/pull/1849 .. _#1988: https://github.com/pallets/flask/pull/1988 .. _#1730: https://github.com/pallets/flask/pull/1730 @@ -376,7 +379,7 @@ Version 0.11.1 Bugfix release, released on June 7th 2016. - Fixed a bug that prevented ``FLASK_APP=foobar/__init__.py`` from working. (`#1872`_) - + .. _#1872: https://github.com/pallets/flask/pull/1872 Version 0.11 @@ -456,7 +459,7 @@ Released on May 29th 2016, codename Absinthe. - Added the ``JSONIFY_MIMETYPE`` configuration variable (`#1728`_). - Exceptions during teardown handling will no longer leave bad application contexts lingering around. - + .. _#1326: https://github.com/pallets/flask/pull/1326 .. _#1393: https://github.com/pallets/flask/pull/1393 .. _#1422: https://github.com/pallets/flask/pull/1422 diff --git a/flask/_compat.py b/flask/_compat.py index a3b5b9c1..7e5b846e 100644 --- a/flask/_compat.py +++ b/flask/_compat.py @@ -97,3 +97,12 @@ if hasattr(sys, 'pypy_version_info'): BROKEN_PYPY_CTXMGR_EXIT = True except AssertionError: pass + + +try: + from os import fspath +except ImportError: + # Backwards compatibility as proposed in PEP 0519: + # https://www.python.org/dev/peps/pep-0519/#backwards-compatibility + def fspath(path): + return path.__fspath__() if hasattr(path, '__fspath__') else path diff --git a/flask/helpers.py b/flask/helpers.py index 7679a496..8a65c2e9 100644 --- a/flask/helpers.py +++ b/flask/helpers.py @@ -33,7 +33,7 @@ from jinja2 import FileSystemLoader from .signals import message_flashed from .globals import session, _request_ctx_stack, _app_ctx_stack, \ current_app, request -from ._compat import string_types, text_type, PY2 +from ._compat import string_types, text_type, PY2, fspath # sentinel _missing = object() @@ -510,6 +510,9 @@ def send_file(filename_or_fp, mimetype=None, as_attachment=False, Filenames are encoded with ASCII instead of Latin-1 for broader compatibility with WSGI servers. + .. versionchanged:: 1.1 + Filenames may be a `PathLike` object. + :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. @@ -538,6 +541,10 @@ def send_file(filename_or_fp, mimetype=None, as_attachment=False, """ mtime = None fsize = None + + if hasattr(filename_or_fp, '__fspath__'): + filename_or_fp = fspath(filename_or_fp) + if isinstance(filename_or_fp, string_types): filename = filename_or_fp if not os.path.isabs(filename): @@ -705,6 +712,8 @@ def send_from_directory(directory, filename, **options): :param options: optional keyword arguments that are directly forwarded to :func:`send_file`. """ + filename = fspath(filename) + directory = fspath(directory) filename = safe_join(directory, filename) if not os.path.isabs(filename): filename = os.path.join(current_app.root_path, filename) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index ae1c0805..e36e0e64 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -36,6 +36,19 @@ def has_encoding(name): return False +class FakePath(object): + """Fake object to represent a ``PathLike object``. + + This represents a ``pathlib.Path`` object in python 3. + See: https://www.python.org/dev/peps/pep-0519/ + """ + def __init__(self, path): + self.path = path + + def __fspath__(self): + return self.path + + class FixedOffset(datetime.tzinfo): """Fixed offset in hours east from UTC. @@ -527,6 +540,15 @@ class TestSendfile(object): assert 'x-sendfile' not in rv.headers rv.close() + def test_send_file_pathlike(self, app, req_ctx): + rv = flask.send_file(FakePath('static/index.html')) + assert rv.direct_passthrough + assert rv.mimetype == 'text/html' + with app.open_resource('static/index.html') as f: + rv.direct_passthrough = False + assert rv.data == f.read() + rv.close() + @pytest.mark.skipif( not callable(getattr(Range, 'to_content_range_header', None)), reason="not implemented within werkzeug" @@ -681,6 +703,12 @@ class TestSendfile(object): assert cc.max_age == 3600 rv.close() + # Test with static file handler. + rv = app.send_static_file(FakePath('index.html')) + cc = parse_cache_control_header(rv.headers['Cache-Control']) + assert cc.max_age == 3600 + rv.close() + class StaticFileApp(flask.Flask): def get_send_file_max_age(self, filename): return 10 @@ -706,6 +734,14 @@ class TestSendfile(object): assert rv.data.strip() == b'Hello Subdomain' rv.close() + def test_send_from_directory_pathlike(self, app, req_ctx): + app.root_path = os.path.join(os.path.dirname(__file__), + 'test_apps', 'subdomaintestmodule') + rv = flask.send_from_directory(FakePath('static'), FakePath('hello.txt')) + rv.direct_passthrough = False + assert rv.data.strip() == b'Hello Subdomain' + rv.close() + def test_send_from_directory_bad_request(self, app, req_ctx): app.root_path = os.path.join(os.path.dirname(__file__), 'test_apps', 'subdomaintestmodule')