diff --git a/CHANGES.rst b/CHANGES.rst index 446d2963..1a3f570c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,22 @@ Version 2.0.1 Unreleased +- Re-add the ``filename`` parameter in ``send_from_directory``. The + ``filename`` parameter has been renamed to ``path``, the old name + is deprecated. :pr:`4019` +- Mark top-level names as exported so type checking understands + imports in user projects. :issue:`4024` +- Fix type annotation for ``g`` and inform mypy that it is a namespace + object that has arbitrary attributes. :issue:`4020` +- Fix some types that weren't available in Python 3.6.0. :issue:`4040` +- Improve typing for ``send_file``, ``send_from_directory``, and + ``get_send_file_max_age``. :issue:`4044`, :pr:`4026` +- Show an error when a blueprint name contains a dot. The ``.`` has + special meaning, it is used to separate (nested) blueprint names and + the endpoint name. :issue:`4041` +- Combine URL prefixes when nesting blueprints that were created with + a ``url_prefix`` value. :issue:`4037` + Version 2.0.0 ------------- diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 1ae1b72a..64c8e197 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -129,7 +129,7 @@ First time setup .. _username: https://docs.github.com/en/github/using-git/setting-your-username-in-git .. _email: https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/setting-your-commit-email-address .. _GitHub account: https://github.com/join -.. _Fork: https://github.com/pallets/jinja/fork +.. _Fork: https://github.com/pallets/flask/fork .. _Clone: https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#step-2-create-a-local-clone-of-your-fork diff --git a/docs/deploying/index.rst b/docs/deploying/index.rst index 4511bf45..e1ed9269 100644 --- a/docs/deploying/index.rst +++ b/docs/deploying/index.rst @@ -16,6 +16,7 @@ Hosted options - `Deploying Flask on Heroku `_ - `Deploying Flask on Google App Engine `_ +- `Deploying Flask on Google Cloud Run `_ - `Deploying Flask on AWS Elastic Beanstalk `_ - `Deploying on Azure (IIS) `_ - `Deploying on PythonAnywhere `_ diff --git a/src/flask/__init__.py b/src/flask/__init__.py index 5c54b9d3..3e7198a6 100644 --- a/src/flask/__init__.py +++ b/src/flask/__init__.py @@ -1,46 +1,46 @@ from markupsafe import escape from markupsafe import Markup -from werkzeug.exceptions import abort -from werkzeug.utils import redirect +from werkzeug.exceptions import abort as abort +from werkzeug.utils import redirect as redirect -from . import json -from .app import Flask -from .app import Request -from .app import Response -from .blueprints import Blueprint -from .config import Config -from .ctx import after_this_request -from .ctx import copy_current_request_context -from .ctx import has_app_context -from .ctx import has_request_context -from .globals import _app_ctx_stack -from .globals import _request_ctx_stack -from .globals import current_app -from .globals import g -from .globals import request -from .globals import session -from .helpers import flash -from .helpers import get_flashed_messages -from .helpers import get_template_attribute -from .helpers import make_response -from .helpers import safe_join -from .helpers import send_file -from .helpers import send_from_directory -from .helpers import stream_with_context -from .helpers import url_for -from .json import jsonify -from .signals import appcontext_popped -from .signals import appcontext_pushed -from .signals import appcontext_tearing_down -from .signals import before_render_template -from .signals import got_request_exception -from .signals import message_flashed -from .signals import request_finished -from .signals import request_started -from .signals import request_tearing_down -from .signals import signals_available -from .signals import template_rendered -from .templating import render_template -from .templating import render_template_string +from . import json as json +from .app import Flask as Flask +from .app import Request as Request +from .app import Response as Response +from .blueprints import Blueprint as Blueprint +from .config import Config as Config +from .ctx import after_this_request as after_this_request +from .ctx import copy_current_request_context as copy_current_request_context +from .ctx import has_app_context as has_app_context +from .ctx import has_request_context as has_request_context +from .globals import _app_ctx_stack as _app_ctx_stack +from .globals import _request_ctx_stack as _request_ctx_stack +from .globals import current_app as current_app +from .globals import g as g +from .globals import request as request +from .globals import session as session +from .helpers import flash as flash +from .helpers import get_flashed_messages as get_flashed_messages +from .helpers import get_template_attribute as get_template_attribute +from .helpers import make_response as make_response +from .helpers import safe_join as safe_join +from .helpers import send_file as send_file +from .helpers import send_from_directory as send_from_directory +from .helpers import stream_with_context as stream_with_context +from .helpers import url_for as url_for +from .json import jsonify as jsonify +from .signals import appcontext_popped as appcontext_popped +from .signals import appcontext_pushed as appcontext_pushed +from .signals import appcontext_tearing_down as appcontext_tearing_down +from .signals import before_render_template as before_render_template +from .signals import got_request_exception as got_request_exception +from .signals import message_flashed as message_flashed +from .signals import request_finished as request_finished +from .signals import request_started as request_started +from .signals import request_tearing_down as request_tearing_down +from .signals import signals_available as signals_available +from .signals import template_rendered as template_rendered +from .templating import render_template as render_template +from .templating import render_template_string as render_template_string __version__ = "2.1.0.dev0" diff --git a/src/flask/app.py b/src/flask/app.py index f8856a52..f0f31486 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -72,6 +72,7 @@ from .wrappers import Request from .wrappers import Response if t.TYPE_CHECKING: + import typing_extensions as te from .blueprints import Blueprint from .testing import FlaskClient from .testing import FlaskCliRunner @@ -1441,7 +1442,7 @@ class Flask(Scaffold): f"Exception on {request.path} [{request.method}]", exc_info=exc_info ) - def raise_routing_exception(self, request: Request) -> t.NoReturn: + def raise_routing_exception(self, request: Request) -> "te.NoReturn": """Exceptions that are recording during routing are reraised with this method. During debug we are not reraising redirect requests for non ``GET``, ``HEAD``, or ``OPTIONS`` requests and we're raising diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 5fb84d86..7bfef84b 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -188,6 +188,10 @@ class Blueprint(Scaffold): template_folder=template_folder, root_path=root_path, ) + + if "." in name: + raise ValueError("'name' may not contain a dot '.' character.") + self.name = name self.url_prefix = url_prefix self.subdomain = subdomain @@ -256,7 +260,7 @@ class Blueprint(Scaffold): """Called by :meth:`Flask.register_blueprint` to register all views and callbacks registered on the blueprint with the application. Creates a :class:`.BlueprintSetupState` and calls - each :meth:`record` callbackwith it. + each :meth:`record` callback with it. :param app: The application this blueprint is being registered with. @@ -340,13 +344,17 @@ class Blueprint(Scaffold): app.cli.add_command(self.cli) for blueprint, bp_options in self._blueprints: - url_prefix = options.get("url_prefix", "") - if "url_prefix" in bp_options: - url_prefix = ( - url_prefix.rstrip("/") + "/" + bp_options["url_prefix"].lstrip("/") + bp_options = bp_options.copy() + bp_url_prefix = bp_options.get("url_prefix") + + if bp_url_prefix is None: + bp_url_prefix = blueprint.url_prefix + + if state.url_prefix is not None and bp_url_prefix is not None: + bp_options["url_prefix"] = ( + state.url_prefix.rstrip("/") + "/" + bp_url_prefix.lstrip("/") ) - bp_options["url_prefix"] = url_prefix bp_options["name_prefix"] = options.get("name_prefix", "") + self.name + "." blueprint.register(app, bp_options) @@ -360,12 +368,12 @@ class Blueprint(Scaffold): """Like :meth:`Flask.add_url_rule` but for a blueprint. The endpoint for the :func:`url_for` function is prefixed with the name of the blueprint. """ - if endpoint: - assert "." not in endpoint, "Blueprint endpoints should not contain dots" - if view_func and hasattr(view_func, "__name__"): - assert ( - "." not in view_func.__name__ - ), "Blueprint view function name should not contain dots" + if endpoint and "." in endpoint: + raise ValueError("'endpoint' may not contain a dot '.' character.") + + if view_func and hasattr(view_func, "__name__") and "." in view_func.__name__: + raise ValueError("'view_func' name may not contain a dot '.' character.") + self.record(lambda s: s.add_url_rule(rule, endpoint, view_func, **options)) def app_template_filter(self, name: t.Optional[str] = None) -> t.Callable: diff --git a/src/flask/ctx.py b/src/flask/ctx.py index 70de8cad..065edd5f 100644 --- a/src/flask/ctx.py +++ b/src/flask/ctx.py @@ -41,6 +41,24 @@ class _AppCtxGlobals: .. versionadded:: 0.10 """ + # Define attr methods to let mypy know this is a namespace object + # that has arbitrary attributes. + + def __getattr__(self, name: str) -> t.Any: + try: + return self.__dict__[name] + except KeyError: + raise AttributeError(name) from None + + def __setattr__(self, name: str, value: t.Any) -> None: + self.__dict__[name] = value + + def __delattr__(self, name: str) -> None: + try: + del self.__dict__[name] + except KeyError: + raise AttributeError(name) from None + def get(self, name: str, default: t.Optional[t.Any] = None) -> t.Any: """Get an attribute by name, or a default value. Like :meth:`dict.get`. @@ -78,10 +96,10 @@ class _AppCtxGlobals: """ return self.__dict__.setdefault(name, default) - def __contains__(self, item: t.Any) -> bool: + def __contains__(self, item: str) -> bool: return item in self.__dict__ - def __iter__(self) -> t.Iterator: + def __iter__(self) -> t.Iterator[str]: return iter(self.__dict__) def __repr__(self) -> str: diff --git a/src/flask/globals.py b/src/flask/globals.py index 5e6e8c75..6d91c75e 100644 --- a/src/flask/globals.py +++ b/src/flask/globals.py @@ -6,7 +6,7 @@ from werkzeug.local import LocalStack if t.TYPE_CHECKING: from .app import Flask - from .ctx import AppContext + from .ctx import _AppCtxGlobals from .sessions import SessionMixin from .wrappers import Request @@ -53,5 +53,7 @@ _request_ctx_stack = LocalStack() _app_ctx_stack = LocalStack() current_app: "Flask" = LocalProxy(_find_app) # type: ignore request: "Request" = LocalProxy(partial(_lookup_req_object, "request")) # type: ignore -session: "SessionMixin" = LocalProxy(partial(_lookup_req_object, "session")) # type: ignore # noqa: B950 -g: "AppContext" = LocalProxy(partial(_lookup_app_object, "g")) # type: ignore +session: "SessionMixin" = LocalProxy( # type: ignore + partial(_lookup_req_object, "session") +) +g: "_AppCtxGlobals" = LocalProxy(partial(_lookup_app_object, "g")) # type: ignore diff --git a/src/flask/helpers.py b/src/flask/helpers.py index 109f544f..585b4dea 100644 --- a/src/flask/helpers.py +++ b/src/flask/helpers.py @@ -4,6 +4,7 @@ import socket import sys import typing as t import warnings +from datetime import datetime from datetime import timedelta from functools import update_wrapper from threading import RLock @@ -436,14 +437,16 @@ def get_flashed_messages( def _prepare_send_file_kwargs( - download_name=None, - attachment_filename=None, - etag=None, - add_etags=None, - max_age=None, - cache_timeout=None, - **kwargs, -): + download_name: t.Optional[str] = None, + attachment_filename: t.Optional[str] = None, + etag: t.Optional[t.Union[bool, str]] = None, + add_etags: t.Optional[t.Union[bool]] = None, + max_age: t.Optional[ + t.Union[int, t.Callable[[t.Optional[str]], t.Optional[int]]] + ] = None, + cache_timeout: t.Optional[int] = None, + **kwargs: t.Any, +) -> t.Dict[str, t.Any]: if attachment_filename is not None: warnings.warn( "The 'attachment_filename' parameter has been renamed to" @@ -482,23 +485,25 @@ def _prepare_send_file_kwargs( max_age=max_age, use_x_sendfile=current_app.use_x_sendfile, response_class=current_app.response_class, - _root_path=current_app.root_path, + _root_path=current_app.root_path, # type: ignore ) return kwargs def send_file( - path_or_file, - mimetype=None, - as_attachment=False, - download_name=None, - attachment_filename=None, - conditional=True, - etag=True, - add_etags=None, - last_modified=None, - max_age=None, - cache_timeout=None, + path_or_file: t.Union[os.PathLike, str, t.BinaryIO], + mimetype: t.Optional[str] = None, + as_attachment: bool = False, + download_name: t.Optional[str] = None, + attachment_filename: t.Optional[str] = None, + conditional: bool = True, + etag: t.Union[bool, str] = True, + add_etags: t.Optional[bool] = None, + last_modified: t.Optional[t.Union[datetime, int, float]] = None, + max_age: t.Optional[ + t.Union[int, t.Callable[[t.Optional[str]], t.Optional[int]]] + ] = None, + cache_timeout: t.Optional[int] = None, ): """Send the contents of a file to the client. @@ -642,7 +647,12 @@ def safe_join(directory: str, *pathnames: str) -> str: return path -def send_from_directory(directory: str, path: str, **kwargs: t.Any) -> "Response": +def send_from_directory( + directory: t.Union[os.PathLike, str], + path: t.Union[os.PathLike, str], + filename: t.Optional[str] = None, + **kwargs: t.Any, +) -> "Response": """Send a file from within a directory using :func:`send_file`. .. code-block:: python @@ -666,12 +676,24 @@ def send_from_directory(directory: str, path: str, **kwargs: t.Any) -> "Response ``directory``. :param kwargs: Arguments to pass to :func:`send_file`. + .. versionchanged:: 2.0 + ``path`` replaces the ``filename`` parameter. + .. versionadded:: 2.0 Moved the implementation to Werkzeug. This is now a wrapper to pass some Flask-specific arguments. .. versionadded:: 0.5 """ + if filename is not None: + warnings.warn( + "The 'filename' parameter has been renamed to 'path'. The" + " old name will be removed in Flask 2.1.", + DeprecationWarning, + stacklevel=2, + ) + path = filename + return werkzeug.utils.send_from_directory( # type: ignore directory, path, **_prepare_send_file_kwargs(**kwargs) ) diff --git a/src/flask/scaffold.py b/src/flask/scaffold.py index 56d37ddd..f50c9b1b 100644 --- a/src/flask/scaffold.py +++ b/src/flask/scaffold.py @@ -288,7 +288,7 @@ class Scaffold: self._static_url_path = value - def get_send_file_max_age(self, filename: str) -> t.Optional[int]: + def get_send_file_max_age(self, filename: t.Optional[str]) -> t.Optional[int]: """Used by :func:`send_file` to determine the ``max_age`` cache value for a given file path if it wasn't passed. diff --git a/src/flask/sessions.py b/src/flask/sessions.py index 0e68e884..34b1d0ce 100644 --- a/src/flask/sessions.py +++ b/src/flask/sessions.py @@ -12,6 +12,7 @@ from .helpers import is_ip from .json.tag import TaggedJSONSerializer if t.TYPE_CHECKING: + import typing_extensions as te from .app import Flask from .wrappers import Request, Response @@ -92,7 +93,7 @@ class NullSession(SecureCookieSession): but fail on setting. """ - def _fail(self, *args: t.Any, **kwargs: t.Any) -> t.NoReturn: + def _fail(self, *args: t.Any, **kwargs: t.Any) -> "te.NoReturn": raise RuntimeError( "The session is unavailable because no secret " "key was set. Set the secret_key on the " diff --git a/src/flask/wrappers.py b/src/flask/wrappers.py index 48fcc34b..bfa9d7ce 100644 --- a/src/flask/wrappers.py +++ b/src/flask/wrappers.py @@ -8,6 +8,7 @@ from . import json from .globals import current_app if t.TYPE_CHECKING: + import typing_extensions as te from werkzeug.routing import Rule @@ -91,7 +92,7 @@ class Request(RequestBase): attach_enctype_error_multidict(self) - def on_json_loading_failed(self, e: Exception) -> t.NoReturn: + def on_json_loading_failed(self, e: Exception) -> "te.NoReturn": if current_app and current_app.debug: raise BadRequest(f"Failed to decode JSON object: {e}") diff --git a/tests/test_basic.py b/tests/test_basic.py index d6ec3fe4..7bdeba1e 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1631,7 +1631,7 @@ def test_url_processors(app, client): def test_inject_blueprint_url_defaults(app): - bp = flask.Blueprint("foo.bar.baz", __name__, template_folder="template") + bp = flask.Blueprint("foo", __name__, template_folder="template") @bp.url_defaults def bp_defaults(endpoint, values): @@ -1644,12 +1644,12 @@ def test_inject_blueprint_url_defaults(app): app.register_blueprint(bp) values = dict() - app.inject_url_defaults("foo.bar.baz.view", values) + app.inject_url_defaults("foo.view", values) expected = dict(page="login") assert values == expected with app.test_request_context("/somepage"): - url = flask.url_for("foo.bar.baz.view") + url = flask.url_for("foo.view") expected = "/login" assert url == expected diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index b986ca02..e7724519 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,5 +1,3 @@ -import functools - import pytest from jinja2 import TemplateNotFound from werkzeug.http import parse_cache_control_header @@ -253,28 +251,9 @@ def test_templates_list(test_apps): assert templates == ["admin/index.html", "frontend/index.html"] -def test_dotted_names(app, client): - frontend = flask.Blueprint("myapp.frontend", __name__) - backend = flask.Blueprint("myapp.backend", __name__) - - @frontend.route("/fe") - def frontend_index(): - return flask.url_for("myapp.backend.backend_index") - - @frontend.route("/fe2") - def frontend_page2(): - return flask.url_for(".frontend_index") - - @backend.route("/be") - def backend_index(): - return flask.url_for("myapp.frontend.frontend_index") - - app.register_blueprint(frontend) - app.register_blueprint(backend) - - assert client.get("/fe").data.strip() == b"/be" - assert client.get("/fe2").data.strip() == b"/fe" - assert client.get("/be").data.strip() == b"/fe" +def test_dotted_name_not_allowed(app, client): + with pytest.raises(ValueError): + flask.Blueprint("app.ui", __name__) def test_dotted_names_from_app(app, client): @@ -343,62 +322,19 @@ def test_route_decorator_custom_endpoint(app, client): def test_route_decorator_custom_endpoint_with_dots(app, client): bp = flask.Blueprint("bp", __name__) - @bp.route("/foo") - def foo(): - return flask.request.endpoint + with pytest.raises(ValueError): + bp.route("/", endpoint="a.b")(lambda: "") - try: + with pytest.raises(ValueError): + bp.add_url_rule("/", endpoint="a.b") - @bp.route("/bar", endpoint="bar.bar") - def foo_bar(): - return flask.request.endpoint + def view(): + return "" - except AssertionError: - pass - else: - raise AssertionError("expected AssertionError not raised") + view.__name__ = "a.b" - try: - - @bp.route("/bar/123", endpoint="bar.123") - def foo_bar_foo(): - return flask.request.endpoint - - except AssertionError: - pass - else: - raise AssertionError("expected AssertionError not raised") - - def foo_foo_foo(): - pass - - pytest.raises( - AssertionError, - lambda: bp.add_url_rule("/bar/123", endpoint="bar.123", view_func=foo_foo_foo), - ) - - pytest.raises( - AssertionError, bp.route("/bar/123", endpoint="bar.123"), lambda: None - ) - - foo_foo_foo.__name__ = "bar.123" - - pytest.raises( - AssertionError, lambda: bp.add_url_rule("/bar/123", view_func=foo_foo_foo) - ) - - bp.add_url_rule( - "/bar/456", endpoint="foofoofoo", view_func=functools.partial(foo_foo_foo) - ) - - app.register_blueprint(bp, url_prefix="/py") - - assert client.get("/py/foo").data == b"bp.foo" - # The rule's didn't actually made it through - rv = client.get("/py/bar") - assert rv.status_code == 404 - rv = client.get("/py/bar/123") - assert rv.status_code == 404 + with pytest.raises(ValueError): + bp.add_url_rule("/", view_func=view) def test_endpoint_decorator(app, client): @@ -899,3 +835,36 @@ def test_nested_blueprint(app, client): assert client.get("/parent/no").data == b"Parent no" assert client.get("/parent/child/no").data == b"Parent no" assert client.get("/parent/child/grandchild/no").data == b"Grandchild no" + + +def test_nested_blueprint_url_prefix(app, client): + parent = flask.Blueprint("parent", __name__, url_prefix="/parent") + child = flask.Blueprint("child", __name__, url_prefix="/child") + grandchild = flask.Blueprint("grandchild", __name__, url_prefix="/grandchild") + apple = flask.Blueprint("apple", __name__, url_prefix="/apple") + + @parent.route("/") + def parent_index(): + return "Parent" + + @child.route("/") + def child_index(): + return "Child" + + @grandchild.route("/") + def grandchild_index(): + return "Grandchild" + + @apple.route("/") + def apple_index(): + return "Apple" + + child.register_blueprint(grandchild) + child.register_blueprint(apple, url_prefix="/orange") # test overwrite + parent.register_blueprint(child) + app.register_blueprint(parent) + + assert client.get("/parent/").data == b"Parent" + assert client.get("/parent/child/").data == b"Child" + assert client.get("/parent/child/grandchild/").data == b"Grandchild" + assert client.get("/parent/child/orange/").data == b"Apple"