Skip to content

Commit 255461d

Browse files
authored
Merge pull request #4074 from pgjones/bp
blueprints are registered with nested names, can change registered name
2 parents a541c2a + 3257b75 commit 255461d

File tree

6 files changed

+204
-82
lines changed

6 files changed

+204
-82
lines changed

CHANGES.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ Unreleased
2727
removed early. :issue:`4078`
2828
- Improve typing for some functions using ``Callable`` in their type
2929
signatures, focusing on decorator factories. :issue:`4060`
30+
- Nested blueprints are registered with their dotted name. This allows
31+
different blueprints with the same name to be nested at different
32+
locations. :issue:`4069`
33+
- ``register_blueprint`` takes a ``name`` option to change the
34+
(pre-dotted) name the blueprint is registered with. This allows the
35+
same blueprint to be registered multiple times with unique names for
36+
``url_for``. Registering the same blueprint with the same name
37+
multiple times is deprecated. :issue:`1091`
3038

3139

3240
Version 2.0.0

src/flask/app.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from .globals import g
3737
from .globals import request
3838
from .globals import session
39+
from .helpers import _split_blueprint_path
3940
from .helpers import get_debug_flag
4041
from .helpers import get_env
4142
from .helpers import get_flashed_messages
@@ -747,7 +748,7 @@ def update_template_context(self, context: dict) -> None:
747748
] = self.template_context_processors[None]
748749
reqctx = _request_ctx_stack.top
749750
if reqctx is not None:
750-
for bp in self._request_blueprints():
751+
for bp in request.blueprints:
751752
if bp in self.template_context_processors:
752753
funcs = chain(funcs, self.template_context_processors[bp])
753754
orig_ctx = context.copy()
@@ -1018,6 +1019,12 @@ def register_blueprint(self, blueprint: "Blueprint", **options: t.Any) -> None:
10181019
:class:`~flask.blueprints.BlueprintSetupState`. They can be
10191020
accessed in :meth:`~flask.Blueprint.record` callbacks.
10201021
1022+
.. versionchanged:: 2.0.1
1023+
The ``name`` option can be used to change the (pre-dotted)
1024+
name the blueprint is registered with. This allows the same
1025+
blueprint to be registered multiple times with unique names
1026+
for ``url_for``.
1027+
10211028
.. versionadded:: 0.7
10221029
"""
10231030
blueprint.register(self, options)
@@ -1267,7 +1274,7 @@ def _find_error_handler(self, e: Exception) -> t.Optional[ErrorHandlerCallable]:
12671274
exc_class, code = self._get_exc_class_and_code(type(e))
12681275

12691276
for c in [code, None]:
1270-
for name in chain(self._request_blueprints(), [None]):
1277+
for name in chain(request.blueprints, [None]):
12711278
handler_map = self.error_handler_spec[name][c]
12721279

12731280
if not handler_map:
@@ -1788,9 +1795,14 @@ def inject_url_defaults(self, endpoint: str, values: dict) -> None:
17881795
.. versionadded:: 0.7
17891796
"""
17901797
funcs: t.Iterable[URLDefaultCallable] = self.url_default_functions[None]
1798+
17911799
if "." in endpoint:
1792-
bp = endpoint.rsplit(".", 1)[0]
1793-
funcs = chain(funcs, self.url_default_functions[bp])
1800+
# This is called by url_for, which can be called outside a
1801+
# request, can't use request.blueprints.
1802+
bps = _split_blueprint_path(endpoint.rpartition(".")[0])
1803+
bp_funcs = chain.from_iterable(self.url_default_functions[bp] for bp in bps)
1804+
funcs = chain(funcs, bp_funcs)
1805+
17941806
for func in funcs:
17951807
func(endpoint, values)
17961808

@@ -1831,14 +1843,14 @@ def preprocess_request(self) -> t.Optional[ResponseReturnValue]:
18311843
funcs: t.Iterable[URLValuePreprocessorCallable] = self.url_value_preprocessors[
18321844
None
18331845
]
1834-
for bp in self._request_blueprints():
1846+
for bp in request.blueprints:
18351847
if bp in self.url_value_preprocessors:
18361848
funcs = chain(funcs, self.url_value_preprocessors[bp])
18371849
for func in funcs:
18381850
func(request.endpoint, request.view_args)
18391851

18401852
funcs: t.Iterable[BeforeRequestCallable] = self.before_request_funcs[None]
1841-
for bp in self._request_blueprints():
1853+
for bp in request.blueprints:
18421854
if bp in self.before_request_funcs:
18431855
funcs = chain(funcs, self.before_request_funcs[bp])
18441856
for func in funcs:
@@ -1863,7 +1875,7 @@ def process_response(self, response: Response) -> Response:
18631875
"""
18641876
ctx = _request_ctx_stack.top
18651877
funcs: t.Iterable[AfterRequestCallable] = ctx._after_request_functions
1866-
for bp in self._request_blueprints():
1878+
for bp in request.blueprints:
18671879
if bp in self.after_request_funcs:
18681880
funcs = chain(funcs, reversed(self.after_request_funcs[bp]))
18691881
if None in self.after_request_funcs:
@@ -1902,7 +1914,7 @@ def do_teardown_request(
19021914
funcs: t.Iterable[TeardownCallable] = reversed(
19031915
self.teardown_request_funcs[None]
19041916
)
1905-
for bp in self._request_blueprints():
1917+
for bp in request.blueprints:
19061918
if bp in self.teardown_request_funcs:
19071919
funcs = chain(funcs, reversed(self.teardown_request_funcs[bp]))
19081920
for func in funcs:
@@ -2074,9 +2086,3 @@ def __call__(self, environ: dict, start_response: t.Callable) -> t.Any:
20742086
wrapped to apply middleware.
20752087
"""
20762088
return self.wsgi_app(environ, start_response)
2077-
2078-
def _request_blueprints(self) -> t.Iterable[str]:
2079-
if _request_ctx_stack.top.request.blueprint is None:
2080-
return []
2081-
else:
2082-
return reversed(_request_ctx_stack.top.request.blueprint.split("."))

src/flask/blueprints.py

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def __init__(
6767
#: blueprint.
6868
self.url_prefix = url_prefix
6969

70+
self.name = self.options.get("name", blueprint.name)
7071
self.name_prefix = self.options.get("name_prefix", "")
7172

7273
#: A dictionary with URL defaults that is added to each and every
@@ -96,9 +97,10 @@ def add_url_rule(
9697
defaults = self.url_defaults
9798
if "defaults" in options:
9899
defaults = dict(defaults, **options.pop("defaults"))
100+
99101
self.app.add_url_rule(
100102
rule,
101-
f"{self.name_prefix}{self.blueprint.name}.{endpoint}",
103+
f"{self.name_prefix}.{self.name}.{endpoint}".lstrip("."),
102104
view_func,
103105
defaults=defaults,
104106
**options,
@@ -252,8 +254,16 @@ def register_blueprint(self, blueprint: "Blueprint", **options: t.Any) -> None:
252254
arguments passed to this method will override the defaults set
253255
on the blueprint.
254256
257+
.. versionchanged:: 2.0.1
258+
The ``name`` option can be used to change the (pre-dotted)
259+
name the blueprint is registered with. This allows the same
260+
blueprint to be registered multiple times with unique names
261+
for ``url_for``.
262+
255263
.. versionadded:: 2.0
256264
"""
265+
if blueprint is self:
266+
raise ValueError("Cannot register a blueprint on itself")
257267
self._blueprints.append((blueprint, options))
258268

259269
def register(self, app: "Flask", options: dict) -> None:
@@ -266,23 +276,48 @@ def register(self, app: "Flask", options: dict) -> None:
266276
with.
267277
:param options: Keyword arguments forwarded from
268278
:meth:`~Flask.register_blueprint`.
269-
:param first_registration: Whether this is the first time this
270-
blueprint has been registered on the application.
279+
280+
.. versionchanged:: 2.0.1
281+
Nested blueprints are registered with their dotted name.
282+
This allows different blueprints with the same name to be
283+
nested at different locations.
284+
285+
.. versionchanged:: 2.0.1
286+
The ``name`` option can be used to change the (pre-dotted)
287+
name the blueprint is registered with. This allows the same
288+
blueprint to be registered multiple times with unique names
289+
for ``url_for``.
290+
291+
.. versionchanged:: 2.0.1
292+
Registering the same blueprint with the same name multiple
293+
times is deprecated and will become an error in Flask 2.1.
271294
"""
272-
first_registration = False
273-
274-
if self.name in app.blueprints:
275-
assert app.blueprints[self.name] is self, (
276-
"A name collision occurred between blueprints"
277-
f" {self!r} and {app.blueprints[self.name]!r}."
278-
f" Both share the same name {self.name!r}."
279-
f" Blueprints that are created on the fly need unique"
280-
f" names."
281-
)
282-
else:
283-
app.blueprints[self.name] = self
284-
first_registration = True
295+
first_registration = not any(bp is self for bp in app.blueprints.values())
296+
name_prefix = options.get("name_prefix", "")
297+
self_name = options.get("name", self.name)
298+
name = f"{name_prefix}.{self_name}".lstrip(".")
299+
300+
if name in app.blueprints:
301+
existing_at = f" '{name}'" if self_name != name else ""
302+
303+
if app.blueprints[name] is not self:
304+
raise ValueError(
305+
f"The name '{self_name}' is already registered for"
306+
f" a different blueprint{existing_at}. Use 'name='"
307+
" to provide a unique name."
308+
)
309+
else:
310+
import warnings
311+
312+
warnings.warn(
313+
f"The name '{self_name}' is already registered for"
314+
f" this blueprint{existing_at}. Use 'name=' to"
315+
" provide a unique name. This will become an error"
316+
" in Flask 2.1.",
317+
stacklevel=4,
318+
)
285319

320+
app.blueprints[name] = self
286321
self._got_registered_once = True
287322
state = self.make_setup_state(app, options, first_registration)
288323

@@ -298,12 +333,11 @@ def register(self, app: "Flask", options: dict) -> None:
298333

299334
def extend(bp_dict, parent_dict):
300335
for key, values in bp_dict.items():
301-
key = self.name if key is None else f"{self.name}.{key}"
302-
336+
key = name if key is None else f"{name}.{key}"
303337
parent_dict[key].extend(values)
304338

305339
for key, value in self.error_handler_spec.items():
306-
key = self.name if key is None else f"{self.name}.{key}"
340+
key = name if key is None else f"{name}.{key}"
307341
value = defaultdict(
308342
dict,
309343
{
@@ -337,7 +371,7 @@ def extend(bp_dict, parent_dict):
337371
if cli_resolved_group is None:
338372
app.cli.commands.update(self.cli.commands)
339373
elif cli_resolved_group is _sentinel:
340-
self.cli.name = self.name
374+
self.cli.name = name
341375
app.cli.add_command(self.cli)
342376
else:
343377
self.cli.name = cli_resolved_group
@@ -354,10 +388,12 @@ def extend(bp_dict, parent_dict):
354388
bp_options["url_prefix"] = (
355389
state.url_prefix.rstrip("/") + "/" + bp_url_prefix.lstrip("/")
356390
)
357-
else:
391+
elif bp_url_prefix is not None:
392+
bp_options["url_prefix"] = bp_url_prefix
393+
elif state.url_prefix is not None:
358394
bp_options["url_prefix"] = state.url_prefix
359395

360-
bp_options["name_prefix"] = options.get("name_prefix", "") + self.name + "."
396+
bp_options["name_prefix"] = name
361397
blueprint.register(app, bp_options)
362398

363399
def add_url_rule(

src/flask/helpers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import warnings
77
from datetime import datetime
88
from datetime import timedelta
9+
from functools import lru_cache
910
from functools import update_wrapper
1011
from threading import RLock
1112

@@ -821,3 +822,13 @@ def is_ip(value: str) -> bool:
821822
return True
822823

823824
return False
825+
826+
827+
@lru_cache(maxsize=None)
828+
def _split_blueprint_path(name: str) -> t.List[str]:
829+
out: t.List[str] = [name]
830+
831+
if "." in name:
832+
out.extend(_split_blueprint_path(name.rpartition(".")[0]))
833+
834+
return out

src/flask/wrappers.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from . import json
88
from .globals import current_app
9+
from .helpers import _split_blueprint_path
910

1011
if t.TYPE_CHECKING:
1112
import typing_extensions as te
@@ -59,23 +60,54 @@ def max_content_length(self) -> t.Optional[int]: # type: ignore
5960

6061
@property
6162
def endpoint(self) -> t.Optional[str]:
62-
"""The endpoint that matched the request. This in combination with
63-
:attr:`view_args` can be used to reconstruct the same or a
64-
modified URL. If an exception happened when matching, this will
65-
be ``None``.
63+
"""The endpoint that matched the request URL.
64+
65+
This will be ``None`` if matching failed or has not been
66+
performed yet.
67+
68+
This in combination with :attr:`view_args` can be used to
69+
reconstruct the same URL or a modified URL.
6670
"""
6771
if self.url_rule is not None:
6872
return self.url_rule.endpoint
69-
else:
70-
return None
73+
74+
return None
7175

7276
@property
7377
def blueprint(self) -> t.Optional[str]:
74-
"""The name of the current blueprint"""
75-
if self.url_rule and "." in self.url_rule.endpoint:
76-
return self.url_rule.endpoint.rsplit(".", 1)[0]
77-
else:
78-
return None
78+
"""The registered name of the current blueprint.
79+
80+
This will be ``None`` if the endpoint is not part of a
81+
blueprint, or if URL matching failed or has not been performed
82+
yet.
83+
84+
This does not necessarily match the name the blueprint was
85+
created with. It may have been nested, or registered with a
86+
different name.
87+
"""
88+
endpoint = self.endpoint
89+
90+
if endpoint is not None and "." in endpoint:
91+
return endpoint.rpartition(".")[0]
92+
93+
return None
94+
95+
@property
96+
def blueprints(self) -> t.List[str]:
97+
"""The registered names of the current blueprint upwards through
98+
parent blueprints.
99+
100+
This will be an empty list if there is no current blueprint, or
101+
if URL matching failed.
102+
103+
.. versionadded:: 2.0.1
104+
"""
105+
name = self.blueprint
106+
107+
if name is None:
108+
return []
109+
110+
return _split_blueprint_path(name)
79111

80112
def _load_form_data(self) -> None:
81113
RequestBase._load_form_data(self)

0 commit comments

Comments
 (0)