From c6230153488fe7aa8d62d74ead0fff767d0693f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleksis=20Fraga=20Men=C3=A9ndez?= <44526468+oleksis@users.noreply.github.com> Date: Fri, 21 May 2021 00:33:09 -0400 Subject: [PATCH 01/18] Add update pip and setuptools section (#4061) * Add update pip and setuptools section * Simplify the command to upgrade pip Co-authored-by: Grey Li --- CONTRIBUTING.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 64c8e197..3a9177a4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -112,6 +112,12 @@ First time setup > py -3 -m venv env > env\Scripts\activate +- Upgrade pip and setuptools. + + .. code-block:: text + + $ python -m pip install --upgrade pip setuptools + - Install the development dependencies, then install Flask in editable mode. From 9841b21f4bcbf5087429c6eff94b11f6720227d0 Mon Sep 17 00:00:00 2001 From: Alex Hedges Date: Sat, 15 May 2021 17:38:25 -0400 Subject: [PATCH 02/18] Make add_url_rule() signature consistent This caused a mypy error when I was making another typing improvement, so I am fixing it before committing my other changes. --- src/flask/blueprints.py | 11 ++++++++++- src/flask/scaffold.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 39396ce7..098eca8c 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -365,6 +365,7 @@ class Blueprint(Scaffold): rule: str, endpoint: t.Optional[str] = None, view_func: t.Optional[t.Callable] = None, + provide_automatic_options: t.Optional[bool] = None, **options: t.Any, ) -> None: """Like :meth:`Flask.add_url_rule` but for a blueprint. The endpoint for @@ -376,7 +377,15 @@ class Blueprint(Scaffold): 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)) + self.record( + lambda s: s.add_url_rule( + rule, + endpoint, + view_func, + provide_automatic_options=provide_automatic_options, + **options, + ) + ) def app_template_filter(self, name: t.Optional[str] = None) -> t.Callable: """Register a custom template filter, available application wide. Like diff --git a/src/flask/scaffold.py b/src/flask/scaffold.py index f50c9b1b..20654b6b 100644 --- a/src/flask/scaffold.py +++ b/src/flask/scaffold.py @@ -443,7 +443,7 @@ class Scaffold: view_func: t.Optional[t.Callable] = None, provide_automatic_options: t.Optional[bool] = None, **options: t.Any, - ) -> t.Callable: + ) -> None: """Register a rule for routing incoming requests and building URLs. The :meth:`route` decorator is a shortcut to call this with the ``view_func`` argument. These are equivalent: From 709f701719adb9f7500d17fddaaa61155896a05d Mon Sep 17 00:00:00 2001 From: Alex Hedges Date: Mon, 17 May 2021 14:02:27 -0400 Subject: [PATCH 03/18] Use TypeVar for setupmethod() TypeVar is needed to preserve function signatures. The type cast for update_wrapper is needed because wapper_func can not use the full signature that f does. --- src/flask/scaffold.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/flask/scaffold.py b/src/flask/scaffold.py index 20654b6b..f8f94af1 100644 --- a/src/flask/scaffold.py +++ b/src/flask/scaffold.py @@ -33,8 +33,10 @@ if t.TYPE_CHECKING: # a singleton sentinel value for parameter defaults _sentinel = object() +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) -def setupmethod(f: t.Callable) -> t.Callable: + +def setupmethod(f: F) -> F: """Wraps a method so that it performs a check in debug mode if the first request was already handled. """ @@ -53,7 +55,7 @@ def setupmethod(f: t.Callable) -> t.Callable: ) return f(self, *args, **kwargs) - return update_wrapper(wrapper_func, f) + return t.cast(F, update_wrapper(wrapper_func, f)) class Scaffold: From f6f1665acb3b2f6898ecdbc3c887e4c4a4c08f08 Mon Sep 17 00:00:00 2001 From: Alex Hedges Date: Mon, 17 May 2021 15:30:05 -0400 Subject: [PATCH 04/18] Improve decorator factory type signatures These changes are required to preserve the type signatures of the created decorators. --- src/flask/app.py | 12 +++++++++--- src/flask/blueprints.py | 12 +++++++++--- src/flask/scaffold.py | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/flask/app.py b/src/flask/app.py index f0f31486..cd1c42ad 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -1089,7 +1089,9 @@ class Flask(Scaffold): self.view_functions[endpoint] = view_func @setupmethod - def template_filter(self, name: t.Optional[str] = None) -> t.Callable: + def template_filter( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateFilterCallable], TemplateFilterCallable]: """A decorator that is used to register custom template filter. You can specify a name for the filter, otherwise the function name will be used. Example:: @@ -1121,7 +1123,9 @@ class Flask(Scaffold): self.jinja_env.filters[name or f.__name__] = f @setupmethod - def template_test(self, name: t.Optional[str] = None) -> t.Callable: + def template_test( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateTestCallable], TemplateTestCallable]: """A decorator that is used to register custom template test. You can specify a name for the test, otherwise the function name will be used. Example:: @@ -1162,7 +1166,9 @@ class Flask(Scaffold): self.jinja_env.tests[name or f.__name__] = f @setupmethod - def template_global(self, name: t.Optional[str] = None) -> t.Callable: + def template_global( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateGlobalCallable], TemplateGlobalCallable]: """A decorator that is used to register a custom template global function. You can specify a name for the global function, otherwise the function name will be used. Example:: diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 098eca8c..88883ba7 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -387,7 +387,9 @@ class Blueprint(Scaffold): ) ) - def app_template_filter(self, name: t.Optional[str] = None) -> t.Callable: + def app_template_filter( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateFilterCallable], TemplateFilterCallable]: """Register a custom template filter, available application wide. Like :meth:`Flask.template_filter` but for a blueprint. @@ -417,7 +419,9 @@ class Blueprint(Scaffold): self.record_once(register_template) - def app_template_test(self, name: t.Optional[str] = None) -> t.Callable: + def app_template_test( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateTestCallable], TemplateTestCallable]: """Register a custom template test, available application wide. Like :meth:`Flask.template_test` but for a blueprint. @@ -451,7 +455,9 @@ class Blueprint(Scaffold): self.record_once(register_template) - def app_template_global(self, name: t.Optional[str] = None) -> t.Callable: + def app_template_global( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateGlobalCallable], TemplateGlobalCallable]: """Register a custom template global, available application wide. Like :meth:`Flask.template_global` but for a blueprint. diff --git a/src/flask/scaffold.py b/src/flask/scaffold.py index f8f94af1..239bc46a 100644 --- a/src/flask/scaffold.py +++ b/src/flask/scaffold.py @@ -644,7 +644,7 @@ class Scaffold: @setupmethod def errorhandler( self, code_or_exception: t.Union[t.Type[Exception], int] - ) -> t.Callable: + ) -> t.Callable[[ErrorHandlerCallable], ErrorHandlerCallable]: """Register a function to handle errors by code or exception class. A decorator that is used to register a function given an From 7fb6d797702562e8914bcf895d08e256c8025ff3 Mon Sep 17 00:00:00 2001 From: Alex Hedges Date: Mon, 17 May 2021 17:14:47 -0400 Subject: [PATCH 05/18] Update CHANGES.rst --- CHANGES.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 0b5a939d..57dbf3dc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -25,6 +25,8 @@ Unreleased available in custom URL converters. :issue:`4053` - Re-add deprecated ``Config.from_json``, which was accidentally removed early. :issue:`4078` +- Improve typing for some functions using ``Callable`` in their type + signatures, focusing on decorator factories. :issue:`4060` Version 2.0.0 From 9a54c7d66e170ceae3f0c07eab14d1853c46908a Mon Sep 17 00:00:00 2001 From: Grey Li Date: Fri, 21 May 2021 16:58:58 +0800 Subject: [PATCH 06/18] Add venv and .venv to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 71dafa39..e50a290e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ *.pyc *.pyo env/ +venv/ +.venv/ env* dist/ build/ From 13ee3c63cfa96d9a8c300f8f98ce47d1402d5ec5 Mon Sep 17 00:00:00 2001 From: Grey Li Date: Fri, 21 May 2021 17:46:31 +0800 Subject: [PATCH 07/18] Fix view decorators docs --- docs/patterns/viewdecorators.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/patterns/viewdecorators.rst b/docs/patterns/viewdecorators.rst index b0960b2d..0b0479ef 100644 --- a/docs/patterns/viewdecorators.rst +++ b/docs/patterns/viewdecorators.rst @@ -142,7 +142,7 @@ Here is the code for that decorator:: def decorated_function(*args, **kwargs): template_name = template if template_name is None: - template_name = f"'{request.endpoint.replace('.', '/')}.html'" + template_name = f"{request.endpoint.replace('.', '/')}.html" ctx = f(*args, **kwargs) if ctx is None: ctx = {} From 7318136ed94f9b4b30ac2eac5cb4f6a9a3f49dfc Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 18 May 2021 13:06:21 +0100 Subject: [PATCH 08/18] Fix blueprint nested url_prefix This ensures that the url_prefix is correctly applied, no matter if set during the registration override or when constructing the blueprint. --- src/flask/blueprints.py | 4 ++- tests/test_blueprints.py | 66 +++++++++++++++------------------------- 2 files changed, 28 insertions(+), 42 deletions(-) diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 88883ba7..85870a90 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -354,7 +354,9 @@ class Blueprint(Scaffold): bp_options["url_prefix"] = ( state.url_prefix.rstrip("/") + "/" + bp_url_prefix.lstrip("/") ) - else: + elif bp_url_prefix is not None: + bp_options["url_prefix"] = bp_url_prefix + elif state.url_prefix is not None: bp_options["url_prefix"] = state.url_prefix bp_options["name_prefix"] = options.get("name_prefix", "") + self.name + "." diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 0bae5333..0f9e9db9 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -837,48 +837,32 @@ def test_nested_blueprint(app, client): 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" +@pytest.mark.parametrize( + "parent_init, child_init, parent_registration, child_registration", + [ + ("/parent", "/child", None, None), + ("/parent", None, None, "/child"), + (None, None, "/parent", "/child"), + ("/other", "/something", "/parent", "/child"), + ], +) +def test_nesting_url_prefixes( + parent_init, + child_init, + parent_registration, + child_registration, + app, + client, +) -> None: + parent = flask.Blueprint("parent", __name__, url_prefix=parent_init) + child = flask.Blueprint("child", __name__, url_prefix=child_init) @child.route("/") - def child_index(): - return "Child" + def index(): + return "index" - @grandchild.route("/") - def grandchild_index(): - return "Grandchild" + parent.register_blueprint(child, url_prefix=child_registration) + app.register_blueprint(parent, url_prefix=parent_registration) - @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" - - -def test_nested_blueprint_url_prefix_only_parent_prefix(app, client): - parent = flask.Blueprint("parent", __name__) - child = flask.Blueprint("child", __name__) - - @child.route("/child-endpoint") - def child_index(): - return "Child" - - parent.register_blueprint(child) - app.register_blueprint(parent, url_prefix="/parent") - - assert client.get("/parent/child-endpoint").data == b"Child" + response = client.get("/parent/child/") + assert response.status_code == 200 From 3f36415bf52c06c2f8d669ba2750c2b8b14828ab Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 18 May 2021 13:33:45 +0100 Subject: [PATCH 09/18] Bugfix blueprint naming Following discussions for Flask we've decided to name blueprints based on how they are registered. This allows for two different blueprints to have the same self-name as long as they are registered in different nested positions. This helps users choose better blueprint names. --- src/flask/app.py | 29 +++++++++++++++-------------- src/flask/blueprints.py | 38 +++++++++++++++++++------------------- src/flask/wrappers.py | 16 ++++++++++++++++ 3 files changed, 50 insertions(+), 33 deletions(-) diff --git a/src/flask/app.py b/src/flask/app.py index cd1c42ad..3bf92ceb 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -747,7 +747,7 @@ class Flask(Scaffold): ] = self.template_context_processors[None] reqctx = _request_ctx_stack.top if reqctx is not None: - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.template_context_processors: funcs = chain(funcs, self.template_context_processors[bp]) orig_ctx = context.copy() @@ -1267,7 +1267,7 @@ class Flask(Scaffold): exc_class, code = self._get_exc_class_and_code(type(e)) for c in [code, None]: - for name in chain(self._request_blueprints(), [None]): + for name in chain(request.blueprints, [None]): handler_map = self.error_handler_spec[name][c] if not handler_map: @@ -1788,9 +1788,16 @@ class Flask(Scaffold): .. versionadded:: 0.7 """ funcs: t.Iterable[URLDefaultCallable] = self.url_default_functions[None] + if "." in endpoint: - bp = endpoint.rsplit(".", 1)[0] - funcs = chain(funcs, self.url_default_functions[bp]) + bps: t.List[str] = [endpoint.rsplit(".", 1)[0]] + + while "." in bps[-1]: + bps.append(bps[-1].rpartition(".")[0]) + + for bp in bps: + funcs = chain(funcs, self.url_default_functions[bp]) + for func in funcs: func(endpoint, values) @@ -1831,14 +1838,14 @@ class Flask(Scaffold): funcs: t.Iterable[URLValuePreprocessorCallable] = self.url_value_preprocessors[ None ] - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.url_value_preprocessors: funcs = chain(funcs, self.url_value_preprocessors[bp]) for func in funcs: func(request.endpoint, request.view_args) funcs: t.Iterable[BeforeRequestCallable] = self.before_request_funcs[None] - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.before_request_funcs: funcs = chain(funcs, self.before_request_funcs[bp]) for func in funcs: @@ -1863,7 +1870,7 @@ class Flask(Scaffold): """ ctx = _request_ctx_stack.top funcs: t.Iterable[AfterRequestCallable] = ctx._after_request_functions - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.after_request_funcs: funcs = chain(funcs, reversed(self.after_request_funcs[bp])) if None in self.after_request_funcs: @@ -1902,7 +1909,7 @@ class Flask(Scaffold): funcs: t.Iterable[TeardownCallable] = reversed( self.teardown_request_funcs[None] ) - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.teardown_request_funcs: funcs = chain(funcs, reversed(self.teardown_request_funcs[bp])) for func in funcs: @@ -2074,9 +2081,3 @@ class Flask(Scaffold): wrapped to apply middleware. """ return self.wsgi_app(environ, start_response) - - def _request_blueprints(self) -> t.Iterable[str]: - if _request_ctx_stack.top.request.blueprint is None: - return [] - else: - return reversed(_request_ctx_stack.top.request.blueprint.split(".")) diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 85870a90..8fe7d9e9 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -98,7 +98,7 @@ class BlueprintSetupState: defaults = dict(defaults, **options.pop("defaults")) self.app.add_url_rule( rule, - f"{self.name_prefix}{self.blueprint.name}.{endpoint}", + f"{self.name_prefix}.{self.blueprint.name}.{endpoint}".lstrip("."), view_func, defaults=defaults, **options, @@ -266,23 +266,24 @@ class Blueprint(Scaffold): with. :param options: Keyword arguments forwarded from :meth:`~Flask.register_blueprint`. - :param first_registration: Whether this is the first time this - blueprint has been registered on the application. """ - first_registration = False + first_registration = True - if self.name in app.blueprints: - assert app.blueprints[self.name] is self, ( - "A name collision occurred between blueprints" - f" {self!r} and {app.blueprints[self.name]!r}." - f" Both share the same name {self.name!r}." - f" Blueprints that are created on the fly need unique" - f" names." + for blueprint in app.blueprints.values(): + if blueprint is self: + first_registration = False + + name_prefix = options.get("name_prefix", "") + name = f"{name_prefix}.{self.name}".lstrip(".") + + if name in app.blueprints and app.blueprints[name] is not self: + raise ValueError( + f"Blueprint name '{self.name}' " + f"is already registered by {app.blueprints[self.name]}. " + "Blueprints must have unique names." ) - else: - app.blueprints[self.name] = self - first_registration = True + app.blueprints[name] = self self._got_registered_once = True state = self.make_setup_state(app, options, first_registration) @@ -298,12 +299,11 @@ class Blueprint(Scaffold): def extend(bp_dict, parent_dict): for key, values in bp_dict.items(): - key = self.name if key is None else f"{self.name}.{key}" - + key = name if key is None else f"{name}.{key}" parent_dict[key].extend(values) for key, value in self.error_handler_spec.items(): - key = self.name if key is None else f"{self.name}.{key}" + key = name if key is None else f"{name}.{key}" value = defaultdict( dict, { @@ -337,7 +337,7 @@ class Blueprint(Scaffold): if cli_resolved_group is None: app.cli.commands.update(self.cli.commands) elif cli_resolved_group is _sentinel: - self.cli.name = self.name + self.cli.name = name app.cli.add_command(self.cli) else: self.cli.name = cli_resolved_group @@ -359,7 +359,7 @@ class Blueprint(Scaffold): elif state.url_prefix is not None: bp_options["url_prefix"] = state.url_prefix - bp_options["name_prefix"] = options.get("name_prefix", "") + self.name + "." + bp_options["name_prefix"] = name blueprint.register(app, bp_options) def add_url_rule( diff --git a/src/flask/wrappers.py b/src/flask/wrappers.py index bfa9d7ce..547e68d6 100644 --- a/src/flask/wrappers.py +++ b/src/flask/wrappers.py @@ -1,6 +1,7 @@ import typing as t from werkzeug.exceptions import BadRequest +from werkzeug.utils import cached_property from werkzeug.wrappers import Request as RequestBase from werkzeug.wrappers import Response as ResponseBase @@ -77,6 +78,21 @@ class Request(RequestBase): else: return None + @cached_property + def blueprints(self) -> t.List[str]: + """The names of the current blueprint upwards through parent + blueprints. + """ + if self.blueprint is None: + return [] + + bps: t.List[str] = [self.blueprint] + + while "." in bps[-1]: + bps.append(bps[-1].rpartition(".")[0]) + + return bps + def _load_form_data(self) -> None: RequestBase._load_form_data(self) From b6835d21446ada84fa0deedf09b5136d15e87e5f Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 18 May 2021 13:37:11 +0100 Subject: [PATCH 10/18] Bugfix allow blueprints to be registered with a different name This allows the same blueprint to be registered multiple times at the same level, but with differing url_prefixes and names. --- src/flask/blueprints.py | 3 ++- tests/test_blueprints.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 8fe7d9e9..61b0ac69 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -274,7 +274,8 @@ class Blueprint(Scaffold): first_registration = False name_prefix = options.get("name_prefix", "") - name = f"{name_prefix}.{self.name}".lstrip(".") + self_name = options.get("name", self.name) + name = f"{name_prefix}.{self_name}".lstrip(".") if name in app.blueprints and app.blueprints[name] is not self: raise ValueError( diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 0f9e9db9..2a93e8fa 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -866,3 +866,16 @@ def test_nesting_url_prefixes( response = client.get("/parent/child/") assert response.status_code == 200 + + +def test_unique_blueprint_names(app, client) -> None: + bp = flask.Blueprint("bp", __name__) + bp2 = flask.Blueprint("bp", __name__) + + app.register_blueprint(bp) + app.register_blueprint(bp) # same name, same object, no error + + with pytest.raises(ValueError): + app.register_blueprint(bp2) # same name, different object + + app.register_blueprint(bp2, name="alt") # different name From c6d18023d346aa6403562f36442f613fb20282ad Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 20 May 2021 10:32:28 -0700 Subject: [PATCH 11/18] cache blueprint path calculation --- src/flask/app.py | 13 +++++---- src/flask/helpers.py | 11 ++++++++ src/flask/wrappers.py | 62 +++++++++++++++++++++++++++---------------- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/src/flask/app.py b/src/flask/app.py index 3bf92ceb..315633b5 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -36,6 +36,7 @@ from .globals import _request_ctx_stack from .globals import g from .globals import request from .globals import session +from .helpers import _split_blueprint_path from .helpers import get_debug_flag from .helpers import get_env from .helpers import get_flashed_messages @@ -1790,13 +1791,11 @@ class Flask(Scaffold): funcs: t.Iterable[URLDefaultCallable] = self.url_default_functions[None] if "." in endpoint: - bps: t.List[str] = [endpoint.rsplit(".", 1)[0]] - - while "." in bps[-1]: - bps.append(bps[-1].rpartition(".")[0]) - - for bp in bps: - funcs = chain(funcs, self.url_default_functions[bp]) + # This is called by url_for, which can be called outside a + # request, can't use request.blueprints. + bps = _split_blueprint_path(endpoint.rpartition(".")[0]) + bp_funcs = chain.from_iterable(self.url_default_functions[bp] for bp in bps) + funcs = chain(funcs, bp_funcs) for func in funcs: func(endpoint, values) diff --git a/src/flask/helpers.py b/src/flask/helpers.py index 585b4dea..57ec9ebf 100644 --- a/src/flask/helpers.py +++ b/src/flask/helpers.py @@ -6,6 +6,7 @@ import typing as t import warnings from datetime import datetime from datetime import timedelta +from functools import lru_cache from functools import update_wrapper from threading import RLock @@ -821,3 +822,13 @@ def is_ip(value: str) -> bool: return True return False + + +@lru_cache(maxsize=None) +def _split_blueprint_path(name: str) -> t.List[str]: + out: t.List[str] = [name] + + if "." in name: + out.extend(_split_blueprint_path(name.rpartition(".")[0])) + + return out diff --git a/src/flask/wrappers.py b/src/flask/wrappers.py index 547e68d6..47dbe5c8 100644 --- a/src/flask/wrappers.py +++ b/src/flask/wrappers.py @@ -1,12 +1,12 @@ import typing as t from werkzeug.exceptions import BadRequest -from werkzeug.utils import cached_property from werkzeug.wrappers import Request as RequestBase from werkzeug.wrappers import Response as ResponseBase from . import json from .globals import current_app +from .helpers import _split_blueprint_path if t.TYPE_CHECKING: import typing_extensions as te @@ -60,38 +60,54 @@ class Request(RequestBase): @property def endpoint(self) -> t.Optional[str]: - """The endpoint that matched the request. This in combination with - :attr:`view_args` can be used to reconstruct the same or a - modified URL. If an exception happened when matching, this will - be ``None``. + """The endpoint that matched the request URL. + + This will be ``None`` if matching failed or has not been + performed yet. + + This in combination with :attr:`view_args` can be used to + reconstruct the same URL or a modified URL. """ if self.url_rule is not None: return self.url_rule.endpoint - else: - return None + + return None @property def blueprint(self) -> t.Optional[str]: - """The name of the current blueprint""" - if self.url_rule and "." in self.url_rule.endpoint: - return self.url_rule.endpoint.rsplit(".", 1)[0] - else: - return None + """The registered name of the current blueprint. - @cached_property - def blueprints(self) -> t.List[str]: - """The names of the current blueprint upwards through parent - blueprints. + This will be ``None`` if the endpoint is not part of a + blueprint, or if URL matching failed or has not been performed + yet. + + This does not necessarily match the name the blueprint was + created with. It may have been nested, or registered with a + different name. """ - if self.blueprint is None: + endpoint = self.endpoint + + if endpoint is not None and "." in endpoint: + return endpoint.rpartition(".")[0] + + return None + + @property + def blueprints(self) -> t.List[str]: + """The registered names of the current blueprint upwards through + parent blueprints. + + This will be an empty list if there is no current blueprint, or + if URL matching failed. + + .. versionadded:: 2.0.1 + """ + name = self.blueprint + + if name is None: return [] - bps: t.List[str] = [self.blueprint] - - while "." in bps[-1]: - bps.append(bps[-1].rpartition(".")[0]) - - return bps + return _split_blueprint_path(name) def _load_form_data(self) -> None: RequestBase._load_form_data(self) From 49079991884d6d1b2fc2f41a4429cb9c211c25a5 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 20 May 2021 11:05:36 -0700 Subject: [PATCH 12/18] changelog for blueprint registered name --- CHANGES.rst | 7 +++++++ src/flask/app.py | 6 ++++++ src/flask/blueprints.py | 17 +++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 57dbf3dc..f50a7ad9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -27,6 +27,13 @@ Unreleased removed early. :issue:`4078` - Improve typing for some functions using ``Callable`` in their type signatures, focusing on decorator factories. :issue:`4060` +- Nested blueprints are registered with their dotted name. This allows + different blueprints with the same name to be nested at different + locations. :issue:`4069` +- ``register_blueprint`` takes a ``name`` option to change the + (pre-dotted) name the blueprint is registered with. This allows the + same blueprint to be registered multiple times with unique names for + ``url_for``. :issue:`1091` Version 2.0.0 diff --git a/src/flask/app.py b/src/flask/app.py index 315633b5..3abce3ce 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -1019,6 +1019,12 @@ class Flask(Scaffold): :class:`~flask.blueprints.BlueprintSetupState`. They can be accessed in :meth:`~flask.Blueprint.record` callbacks. + .. versionchanged:: 2.0.1 + The ``name`` option can be used to change the (pre-dotted) + name the blueprint is registered with. This allows the same + blueprint to be registered multiple times with unique names + for ``url_for``. + .. versionadded:: 0.7 """ blueprint.register(self, options) diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 61b0ac69..6b4e2714 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -252,6 +252,12 @@ class Blueprint(Scaffold): arguments passed to this method will override the defaults set on the blueprint. + .. versionchanged:: 2.0.1 + The ``name`` option can be used to change the (pre-dotted) + name the blueprint is registered with. This allows the same + blueprint to be registered multiple times with unique names + for ``url_for``. + .. versionadded:: 2.0 """ self._blueprints.append((blueprint, options)) @@ -266,6 +272,17 @@ class Blueprint(Scaffold): with. :param options: Keyword arguments forwarded from :meth:`~Flask.register_blueprint`. + + .. versionchanged:: 2.0.1 + Nested blueprints are registered with their dotted name. + This allows different blueprints with the same name to be + nested at different locations. + + .. versionchanged:: 2.0.1 + The ``name`` option can be used to change the (pre-dotted) + name the blueprint is registered with. This allows the same + blueprint to be registered multiple times with unique names + for ``url_for``. """ first_registration = True From 020ac5f2d9d681a71a874a897b36b60295ea8c78 Mon Sep 17 00:00:00 2001 From: David Lord Date: Thu, 20 May 2021 13:08:28 -0700 Subject: [PATCH 13/18] warn when registering same blueprint with same name --- CHANGES.rst | 3 ++- src/flask/blueprints.py | 36 ++++++++++++++++++++++++------------ tests/test_blueprints.py | 12 ++++++++---- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f50a7ad9..2698276e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -33,7 +33,8 @@ Unreleased - ``register_blueprint`` takes a ``name`` option to change the (pre-dotted) name the blueprint is registered with. This allows the same blueprint to be registered multiple times with unique names for - ``url_for``. :issue:`1091` + ``url_for``. Registering the same blueprint with the same name + multiple times is deprecated. :issue:`1091` Version 2.0.0 diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 6b4e2714..fc999c75 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -283,23 +283,35 @@ class Blueprint(Scaffold): name the blueprint is registered with. This allows the same blueprint to be registered multiple times with unique names for ``url_for``. + + .. versionchanged:: 2.0.1 + Registering the same blueprint with the same name multiple + times is deprecated and will become an error in Flask 2.1. """ - first_registration = True - - for blueprint in app.blueprints.values(): - if blueprint is self: - first_registration = False - + first_registration = not any(bp is self for bp in app.blueprints.values()) name_prefix = options.get("name_prefix", "") self_name = options.get("name", self.name) name = f"{name_prefix}.{self_name}".lstrip(".") - if name in app.blueprints and app.blueprints[name] is not self: - raise ValueError( - f"Blueprint name '{self.name}' " - f"is already registered by {app.blueprints[self.name]}. " - "Blueprints must have unique names." - ) + if name in app.blueprints: + existing_at = f" '{name}'" if self_name != name else "" + + if app.blueprints[name] is not self: + raise ValueError( + f"The name '{self_name}' is already registered for" + f" a different blueprint{existing_at}. Use 'name='" + " to provide a unique name." + ) + else: + import warnings + + warnings.warn( + f"The name '{self_name}' is already registered for" + f" this blueprint{existing_at}. Use 'name=' to" + " provide a unique name. This will become an error" + " in Flask 2.1.", + stacklevel=4, + ) app.blueprints[name] = self self._got_registered_once = True diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 2a93e8fa..2977f69c 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -140,7 +140,7 @@ def test_blueprint_url_defaults(app, client): return str(bar) app.register_blueprint(bp, url_prefix="/1", url_defaults={"bar": 23}) - app.register_blueprint(bp, url_prefix="/2", url_defaults={"bar": 19}) + app.register_blueprint(bp, name="test2", url_prefix="/2", url_defaults={"bar": 19}) assert client.get("/1/foo").data == b"23/42" assert client.get("/2/foo").data == b"19/42" @@ -873,9 +873,13 @@ def test_unique_blueprint_names(app, client) -> None: bp2 = flask.Blueprint("bp", __name__) app.register_blueprint(bp) - app.register_blueprint(bp) # same name, same object, no error + + with pytest.warns(UserWarning): + app.register_blueprint(bp) # same bp, same name, warning + + app.register_blueprint(bp, name="again") # same bp, different name, ok with pytest.raises(ValueError): - app.register_blueprint(bp2) # same name, different object + app.register_blueprint(bp2) # different bp, same name, error - app.register_blueprint(bp2, name="alt") # different name + app.register_blueprint(bp2, name="alt") # different bp, different name, ok From b8f5c161f8c5afa0ba89a0b3591809854942fd28 Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 21 May 2021 15:01:32 +0100 Subject: [PATCH 14/18] Fix blueprint self registration By raising a ValueError if attempted. I don't see a use case that makes this worth supporting. --- src/flask/blueprints.py | 2 ++ tests/test_blueprints.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index fc999c75..2da4d12b 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -260,6 +260,8 @@ class Blueprint(Scaffold): .. versionadded:: 2.0 """ + if blueprint is self: + raise ValueError("Cannot register a blueprint on itself") self._blueprints.append((blueprint, options)) def register(self, app: "Flask", options: dict) -> None: diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 2977f69c..73c94ade 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -883,3 +883,9 @@ def test_unique_blueprint_names(app, client) -> None: app.register_blueprint(bp2) # different bp, same name, error app.register_blueprint(bp2, name="alt") # different bp, different name, ok + + +def test_self_registration(app, client) -> None: + bp = flask.Blueprint("bp", __name__) + with pytest.raises(ValueError): + bp.register_blueprint(bp) From 80836ecf648e0098a2000e6b6eb1cfed74b2e9f8 Mon Sep 17 00:00:00 2001 From: pgjones Date: Fri, 21 May 2021 15:02:05 +0100 Subject: [PATCH 15/18] Fix blueprint renaming This ensures that if a blueprint is renamed at the time of registration that name is used when constructing endpoints, as expected. --- src/flask/blueprints.py | 4 +++- tests/test_blueprints.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 2da4d12b..f3913b30 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -67,6 +67,7 @@ class BlueprintSetupState: #: blueprint. self.url_prefix = url_prefix + self.name = self.options.get("name", blueprint.name) self.name_prefix = self.options.get("name_prefix", "") #: A dictionary with URL defaults that is added to each and every @@ -96,9 +97,10 @@ class BlueprintSetupState: defaults = self.url_defaults if "defaults" in options: defaults = dict(defaults, **options.pop("defaults")) + self.app.add_url_rule( rule, - f"{self.name_prefix}.{self.blueprint.name}.{endpoint}".lstrip("."), + f"{self.name_prefix}.{self.name}.{endpoint}".lstrip("."), view_func, defaults=defaults, **options, diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 73c94ade..088ad779 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -889,3 +889,25 @@ def test_self_registration(app, client) -> None: bp = flask.Blueprint("bp", __name__) with pytest.raises(ValueError): bp.register_blueprint(bp) + + +def test_blueprint_renaming(app, client) -> None: + bp = flask.Blueprint("bp", __name__) + bp2 = flask.Blueprint("bp2", __name__) + + @bp.get("/") + def index(): + return flask.request.endpoint + + @bp2.get("/") + def index2(): + return flask.request.endpoint + + bp.register_blueprint(bp2, url_prefix="/a", name="sub") + app.register_blueprint(bp, url_prefix="/a") + app.register_blueprint(bp, url_prefix="/b", name="alt") + + assert client.get("/a/").data == b"bp.index" + assert client.get("/b/").data == b"alt.index" + assert client.get("/a/a/").data == b"bp.sub.index2" + assert client.get("/b/a/").data == b"alt.sub.index2" From d883eb2ab54e987617d1440ab023f95c9e5d2f66 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 21 May 2021 08:42:44 -0700 Subject: [PATCH 16/18] improve typing for `stream_with_context` --- CHANGES.rst | 1 + src/flask/helpers.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2698276e..9e987892 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -35,6 +35,7 @@ Unreleased same blueprint to be registered multiple times with unique names for ``url_for``. Registering the same blueprint with the same name multiple times is deprecated. :issue:`1091` +- Improve typing for ``stream_with_context``. :issue:`4052` Version 2.0.0 diff --git a/src/flask/helpers.py b/src/flask/helpers.py index 57ec9ebf..7b8b0870 100644 --- a/src/flask/helpers.py +++ b/src/flask/helpers.py @@ -64,8 +64,10 @@ def get_load_dotenv(default: bool = True) -> bool: def stream_with_context( - generator_or_function: t.Union[t.Generator, t.Callable] -) -> t.Generator: + generator_or_function: t.Union[ + t.Iterator[t.AnyStr], t.Callable[..., t.Iterator[t.AnyStr]] + ] +) -> t.Iterator[t.AnyStr]: """Request contexts disappear when the response is started on the server. This is done for efficiency reasons and to make it less likely to encounter memory leaks with badly written WSGI middlewares. The downside is that if From e681adb826dfad4cd79c14285da065c899baee25 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 21 May 2021 08:50:31 -0700 Subject: [PATCH 17/18] release version 2.0.1 --- CHANGES.rst | 2 +- src/flask/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 9e987892..d7fe44b9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,7 +3,7 @@ Version 2.0.1 ------------- -Unreleased +Released 2021-05-21 - Re-add the ``filename`` parameter in ``send_from_directory``. The ``filename`` parameter has been renamed to ``path``, the old name diff --git a/src/flask/__init__.py b/src/flask/__init__.py index 008e1a81..c5da045e 100644 --- a/src/flask/__init__.py +++ b/src/flask/__init__.py @@ -43,4 +43,4 @@ 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.0.1.dev0" +__version__ = "2.0.1" From af4a5b841e4018cea3e6b3c8697247bc6fc062f0 Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 21 May 2021 08:55:31 -0700 Subject: [PATCH 18/18] start version 2.0.2.dev0 --- CHANGES.rst | 6 ++++++ src/flask/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index d7fe44b9..006c0a1f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ .. currentmodule:: flask +Version 2.0.2 +------------- + +Unreleased + + Version 2.0.1 ------------- diff --git a/src/flask/__init__.py b/src/flask/__init__.py index c5da045e..471ba7a6 100644 --- a/src/flask/__init__.py +++ b/src/flask/__init__.py @@ -43,4 +43,4 @@ 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.0.1" +__version__ = "2.0.2.dev0"