Skip to content

Commit bf7529e

Browse files
authored
Add a DuplicateRuleError (pallets#3038)
2 parents b43ad6e + 9bca0b8 commit bf7529e

File tree

6 files changed

+137
-2
lines changed

6 files changed

+137
-2
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Unreleased
4343
are closed to prevent a ``ResourceWarning``. :pr:`3101`
4444
- ``SpooledTemporaryFile`` is always used for multipart file parsing.
4545
:pr:`3101`
46+
- Raise a ``DuplicateRuleError`` when attempting to add a rule to a map with
47+
an equal rule. :issue:`3037`
4648

4749

4850
Version 3.1.5

docs/routing.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ format ``<converter(arguments):name>``. ``converter`` and ``arguments``
7474
``default`` converter is used (``string`` by default). The available
7575
converters are discussed below.
7676

77+
Rule path parts are separated by ``/`` and matched individually, unless a
78+
converter opts in to matching ``/`` as well.
79+
7780
Rules that end with a slash are "branches", others are "leaves". If
7881
``strict_slashes`` is enabled (the default), visiting a branch URL
7982
without a trailing slash will redirect to the URL with a slash appended.
@@ -86,6 +89,18 @@ merged. If you want to disable ``merge_slashes`` for a :class:`Rule` or
8689
:class:`Map`, you'll also need to configure your web server
8790
appropriately.
8891

92+
Besides the path, matching can also use the subdomain of a known base domain if
93+
``subdomain_matching`` is enabled (the default), or the full host (if
94+
``host_matching`` is enabled). The subdomain or host parts can also contain
95+
variables. Unlike the path, where multiple parts are separated by ``/``, the
96+
domain is always matched as a single part.
97+
98+
If a duplicate rule is added to a map, a :exc:`.DuplicateRuleError` will be
99+
raised. Rules are compared based on their path, subdomain or host, and websocket
100+
mode. Variable parts are not equal if they use different converters, although
101+
this heuristic may not be perfect depending on what the different converters can
102+
actually match.
103+
89104

90105
Rule Priority
91106
=============
@@ -148,10 +163,17 @@ Maps, Rules and Adapters
148163
.. autoclass:: Rule
149164
:members: empty
150165

166+
.. currentmodule:: werkzeug.routing.exceptions
167+
168+
.. autoclass:: DuplicateRuleError
169+
:members:
170+
151171

152172
Rule Factories
153173
==============
154174

175+
.. currentmodule:: werkzeug.routing
176+
155177
.. autoclass:: RuleFactory
156178
:members: get_rules
157179

src/werkzeug/routing/exceptions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,29 @@
1717
from .rules import Rule
1818

1919

20+
class DuplicateRuleError(Exception):
21+
"""Raised if a :class:`.Rule` is added to a :class:`.Map` that is equal to
22+
an existing rule in the map.
23+
24+
:param existing: The existing rule that was duplicated.
25+
:param duplicate: The new rule that duplicates the existing rule.
26+
27+
.. versionadded:: 3.2
28+
"""
29+
30+
def __init__(self, existing: Rule, duplicate: Rule) -> None:
31+
super().__init__()
32+
33+
self.existing = existing
34+
"""The existing rule that was duplicated."""
35+
36+
self.duplicate = duplicate
37+
"""The new rule that duplicates the existing rule."""
38+
39+
def __str__(self) -> str:
40+
return str(self.existing)
41+
42+
2043
class RoutingException(Exception):
2144
"""Special exceptions that require the application to redirect, notifying
2245
about missing urls, etc.

src/werkzeug/routing/matcher.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dataclasses import field
77

88
from .converters import ValidationError
9+
from .exceptions import DuplicateRuleError
910
from .exceptions import NoMatch
1011
from .exceptions import RequestAliasRedirect
1112
from .exceptions import RequestPath
@@ -50,6 +51,11 @@ def add(self, rule: Rule) -> None:
5051
new_state = State()
5152
state.dynamic.append((part, new_state))
5253
state = new_state
54+
55+
for existing in state.rules:
56+
if rule == existing:
57+
raise DuplicateRuleError(existing, rule)
58+
5359
state.rules.append(rule)
5460

5561
def update(self) -> None:

src/werkzeug/routing/rules.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -907,12 +907,25 @@ def build_compare_key(self) -> tuple[int, int, int]:
907907
return (1 if self.alias else 0, -len(self.arguments), -len(self.defaults or ()))
908908

909909
def __eq__(self, other: object) -> bool:
910-
return isinstance(other, type(self)) and self._trace == other._trace
910+
if not isinstance(other, type(self)):
911+
return NotImplemented
912+
913+
return self._parts == other._parts and self.websocket == other.websocket
911914

912915
__hash__ = None # type: ignore
913916

914917
def __str__(self) -> str:
915-
return self.rule
918+
out = self.rule
919+
920+
if self.map.subdomain_matching and self.subdomain:
921+
out = f"{self.subdomain} {out}"
922+
elif self.map.host_matching and self.host:
923+
out = f"{self.host} {out}"
924+
925+
if self.websocket:
926+
out = f"websocket {out}"
927+
928+
return f"{out}{self.endpoint}"
916929

917930
def __repr__(self) -> str:
918931
if self.map is None:

tests/test_routing.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from werkzeug.exceptions import MethodNotAllowed
1414
from werkzeug.exceptions import NotFound
1515
from werkzeug.exceptions import SecurityError
16+
from werkzeug.routing.exceptions import DuplicateRuleError
1617
from werkzeug.test import create_environ
1718
from werkzeug.wrappers import Request
1819
from werkzeug.wrappers import Response
@@ -251,6 +252,74 @@ def test_strict_slashes_leaves_dont_consume():
251252
assert adapter.match("/path5/", method="GET") == ("leaf", {})
252253

253254

255+
def test_no_duplicates() -> None:
256+
"""A rule's subdomain part and websocket mode are considered for equality."""
257+
r.Map(
258+
[
259+
r.Rule("/", endpoint="index"),
260+
r.Rule("/", endpoint="websocket", websocket=True),
261+
r.Rule("/", endpoint="sub", subdomain="a"),
262+
]
263+
)
264+
265+
266+
def test_no_duplicates_host() -> None:
267+
"""If host_matching is enabled, a rule's host is considered for equality."""
268+
r.Map(
269+
[
270+
r.Rule("/", endpoint="index", host="a"),
271+
r.Rule("/", endpoint="host", host="b"),
272+
],
273+
host_matching=True,
274+
)
275+
276+
277+
def test_duplicate_domain_disabled() -> None:
278+
"""If domain matching is disabled, a rule's subdomain is not considered for
279+
equality.
280+
"""
281+
with pytest.raises(DuplicateRuleError):
282+
r.Map(
283+
[
284+
r.Rule("/", endpoint="index"),
285+
r.Rule("/", endpoint="sub", subdomain="a"),
286+
],
287+
subdomain_matching=False,
288+
)
289+
290+
291+
def test_duplicate_rule() -> None:
292+
"""Equal static parts are duplicates."""
293+
with pytest.raises(DuplicateRuleError):
294+
r.Map(
295+
[
296+
r.Rule("/", endpoint="a"),
297+
r.Rule("/", endpoint="b"),
298+
]
299+
)
300+
301+
302+
def test_duplicate_converter() -> None:
303+
"""Equal converter parts are duplicates."""
304+
with pytest.raises(DuplicateRuleError):
305+
r.Map(
306+
[
307+
r.Rule("/page/<a>", endpoint="a"),
308+
r.Rule("/page/<b>", endpoint="b"),
309+
]
310+
)
311+
312+
313+
def test_no_duplicate_different_converters() -> None:
314+
"""Converters of different types are not duplicates."""
315+
r.Map(
316+
[
317+
r.Rule("/<a>", endpoint="a"),
318+
r.Rule("/<int:b>", endpoint="b"),
319+
]
320+
)
321+
322+
254323
def test_environ_defaults():
255324
environ = create_environ("/foo")
256325
assert environ["PATH_INFO"] == "/foo"

0 commit comments

Comments
 (0)