summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Rose <offline@offby1.net>2026-05-24 09:59:38 -0700
committerJacob Walls <jacobtylerwalls@gmail.com>2026-06-01 15:24:49 -0400
commit804660d685a5abd49fc66ba20c98d1a523f28f9f (patch)
tree0ff0574ddeb992f9590cd6831671404bf2637d5e
parent2715b7f499ef52217241560705d47fb5e0079620 (diff)
Refs #28800 -- Lifted some url functions from admindocs into urls.utils.
-rw-r--r--django/contrib/admindocs/utils.py96
-rw-r--r--django/contrib/admindocs/views.py54
-rw-r--r--django/urls/utils.py140
-rw-r--r--tests/admin_docs/test_views.py115
-rw-r--r--tests/urlpatterns/tests.py112
5 files changed, 260 insertions, 257 deletions
diff --git a/django/contrib/admindocs/utils.py b/django/contrib/admindocs/utils.py
index 4d9403a6f7..4a9acc9548 100644
--- a/django/contrib/admindocs/utils.py
+++ b/django/contrib/admindocs/utils.py
@@ -6,7 +6,11 @@ from email.parser import HeaderParser
from inspect import cleandoc
from django.urls import reverse
-from django.utils.regex_helper import _lazy_re_compile
+from django.urls.utils import ( # NOQA: F401
+ extract_views_from_urlpatterns,
+ simplify_regex,
+)
+from django.utils.regex_helper import _lazy_re_compile # NOQA: F401
from django.utils.safestring import mark_safe
try:
@@ -173,96 +177,6 @@ if docutils_is_available:
for name, urlbase in ROLES.items():
create_reference_role(name, urlbase)
-# Match the beginning of a named, unnamed, or non-capturing groups.
-named_group_matcher = _lazy_re_compile(r"\(\?P(<\w+>)")
-unnamed_group_matcher = _lazy_re_compile(r"\(")
-non_capturing_group_matcher = _lazy_re_compile(r"\(\?\:")
-
-
-def replace_metacharacters(pattern):
- """Remove unescaped metacharacters from the pattern."""
- return re.sub(
- r"((?:^|(?<!\\))(?:\\\\)*)(\\?)([?*+^$]|\\[bBAZ])",
- lambda m: m[1] + m[3] if m[2] else m[1],
- pattern,
- )
-
-
-def _get_group_start_end(start, end, pattern):
- # Handle nested parentheses, e.g. '^(?P<a>(x|y))/b' or '^b/((x|y)\w+)$'.
- unmatched_open_brackets, prev_char = 1, None
- for idx, val in enumerate(pattern[end:]):
- # Check for unescaped `(` and `)`. They mark the start and end of a
- # nested group.
- if val == "(" and prev_char != "\\":
- unmatched_open_brackets += 1
- elif val == ")" and prev_char != "\\":
- unmatched_open_brackets -= 1
- prev_char = val
- # If brackets are balanced, the end of the string for the current named
- # capture group pattern has been reached.
- if unmatched_open_brackets == 0:
- return start, end + idx + 1
-
-
-def _find_groups(pattern, group_matcher):
- prev_end = None
- for match in group_matcher.finditer(pattern):
- if indices := _get_group_start_end(match.start(0), match.end(0), pattern):
- start, end = indices
- if prev_end and start > prev_end or not prev_end:
- yield start, end, match
- prev_end = end
-
-
-def replace_named_groups(pattern):
- r"""
- Find named groups in `pattern` and replace them with the group name. E.g.,
- 1. ^(?P<a>\w+)/b/(\w+)$ ==> ^<a>/b/(\w+)$
- 2. ^(?P<a>\w+)/b/(?P<c>\w+)/$ ==> ^<a>/b/<c>/$
- 3. ^(?P<a>\w+)/b/(\w+) ==> ^<a>/b/(\w+)
- 4. ^(?P<a>\w+)/b/(?P<c>\w+) ==> ^<a>/b/<c>
- """
- group_pattern_and_name = [
- (pattern[start:end], match[1])
- for start, end, match in _find_groups(pattern, named_group_matcher)
- ]
- for group_pattern, group_name in group_pattern_and_name:
- pattern = pattern.replace(group_pattern, group_name)
- return pattern
-
-
-def replace_unnamed_groups(pattern):
- r"""
- Find unnamed groups in `pattern` and replace them with '<var>'. E.g.,
- 1. ^(?P<a>\w+)/b/(\w+)$ ==> ^(?P<a>\w+)/b/<var>$
- 2. ^(?P<a>\w+)/b/((x|y)\w+)$ ==> ^(?P<a>\w+)/b/<var>$
- 3. ^(?P<a>\w+)/b/(\w+) ==> ^(?P<a>\w+)/b/<var>
- 4. ^(?P<a>\w+)/b/((x|y)\w+) ==> ^(?P<a>\w+)/b/<var>
- """
- final_pattern, prev_end = "", None
- for start, end, _ in _find_groups(pattern, unnamed_group_matcher):
- if prev_end:
- final_pattern += pattern[prev_end:start]
- final_pattern += pattern[:start] + "<var>"
- prev_end = end
- return final_pattern + pattern[prev_end:]
-
-
-def remove_non_capturing_groups(pattern):
- r"""
- Find non-capturing groups in the given `pattern` and remove them, e.g.
- 1. (?P<a>\w+)/b/(?:\w+)c(?:\w+) => (?P<a>\\w+)/b/c
- 2. ^(?:\w+(?:\w+))a => ^a
- 3. ^a(?:\w+)/b(?:\w+) => ^a/b
- """
- group_start_end_indices = _find_groups(pattern, non_capturing_group_matcher)
- final_pattern, prev_end = "", None
- for start, end, _ in group_start_end_indices:
- final_pattern += pattern[prev_end:start]
- prev_end = end
- return final_pattern + pattern[prev_end:]
-
def strip_p_tags(value):
return mark_safe(value.replace("<p>", "").replace("</p>", ""))
diff --git a/django/contrib/admindocs/views.py b/django/contrib/admindocs/views.py
index 0c4ece29fe..6a8453b292 100644
--- a/django/contrib/admindocs/views.py
+++ b/django/contrib/admindocs/views.py
@@ -7,22 +7,16 @@ from django.apps import apps
from django.contrib import admin
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.admindocs import utils
-from django.contrib.admindocs.utils import (
- remove_non_capturing_groups,
- replace_metacharacters,
- replace_named_groups,
- replace_unnamed_groups,
-)
from django.contrib.auth import get_permission_codename
from django.core.exceptions import (
ImproperlyConfigured,
PermissionDenied,
- ViewDoesNotExist,
)
from django.db import models
from django.http import Http404
from django.template.engine import Engine
from django.urls import get_mod_func, get_resolver, get_urlconf
+from django.urls.utils import extract_views_from_urlpatterns, simplify_regex
from django.utils._os import safe_join
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
@@ -475,49 +469,3 @@ def get_readable_field_data_type(field):
the values of field.__dict__ before being output.
"""
return field.description % field.__dict__
-
-
-def extract_views_from_urlpatterns(urlpatterns, base="", namespace=None):
- """
- Return a list of views from a list of urlpatterns.
-
- Each object in the returned list is a 4-tuple:
- (view_func, regex, namespace, name)
- """
- views = []
- for p in urlpatterns:
- if hasattr(p, "url_patterns"):
- try:
- patterns = p.url_patterns
- except ImportError:
- continue
- views.extend(
- extract_views_from_urlpatterns(
- patterns,
- base + str(p.pattern),
- (namespace or []) + (p.namespace and [p.namespace] or []),
- )
- )
- elif hasattr(p, "callback"):
- try:
- views.append((p.callback, base + str(p.pattern), namespace, p.name))
- except ViewDoesNotExist:
- continue
- else:
- raise TypeError(_("%s does not appear to be a urlpattern object") % p)
- return views
-
-
-def simplify_regex(pattern):
- r"""
- Clean up urlpattern regexes into something more readable by humans. For
- example, turn "^(?P<sport_slug>\w+)/athletes/(?P<athlete_slug>\w+)/$"
- into "/<sport_slug>/athletes/<athlete_slug>/".
- """
- pattern = remove_non_capturing_groups(pattern)
- pattern = replace_named_groups(pattern)
- pattern = replace_unnamed_groups(pattern)
- pattern = replace_metacharacters(pattern)
- if not pattern.startswith("/"):
- pattern = "/" + pattern
- return pattern
diff --git a/django/urls/utils.py b/django/urls/utils.py
index b5054b163c..5bf3988361 100644
--- a/django/urls/utils.py
+++ b/django/urls/utils.py
@@ -1,8 +1,11 @@
import functools
+import re
from importlib import import_module
from django.core.exceptions import ViewDoesNotExist
from django.utils.module_loading import module_has_submodule
+from django.utils.regex_helper import _lazy_re_compile
+from django.utils.translation import gettext as _
@functools.cache
@@ -64,3 +67,140 @@ def get_mod_func(callback):
except ValueError:
return callback, ""
return callback[:dot], callback[dot + 1 :]
+
+
+# Match the beginning of a named, unnamed, or non-capturing groups.
+_NAMED_GROUP_MATCHER = _lazy_re_compile(r"\(\?P(<\w+>)")
+_UNNAMED_GROUP_MATCHER = _lazy_re_compile(r"\(")
+_NON_CAPTURING_GROUP_MATCHER = _lazy_re_compile(r"\(\?\:")
+
+
+def replace_metacharacters(pattern):
+ """Remove unescaped metacharacters from the pattern."""
+ return re.sub(
+ r"((?:^|(?<!\\))(?:\\\\)*)(\\?)([?*+^$]|\\[bBAZ])",
+ lambda m: m[1] + m[3] if m[2] else m[1],
+ pattern,
+ )
+
+
+def _get_group_start_end(start, end, pattern):
+ # Handle nested parentheses, e.g. '^(?P<a>(x|y))/b' or '^b/((x|y)\w+)$'.
+ unmatched_open_brackets, prev_char = 1, None
+ for idx, val in enumerate(pattern[end:]):
+ # Check for unescaped `(` and `)`. They mark the start and end of a
+ # nested group.
+ if val == "(" and prev_char != "\\":
+ unmatched_open_brackets += 1
+ elif val == ")" and prev_char != "\\":
+ unmatched_open_brackets -= 1
+ prev_char = val
+ # If brackets are balanced, the end of the string for the current named
+ # capture group pattern has been reached.
+ if unmatched_open_brackets == 0:
+ return start, end + idx + 1
+
+
+def _find_groups(pattern, group_matcher):
+ prev_end = None
+ for match in group_matcher.finditer(pattern):
+ if indices := _get_group_start_end(match.start(0), match.end(0), pattern):
+ start, end = indices
+ if prev_end and start > prev_end or not prev_end:
+ yield start, end, match
+ prev_end = end
+
+
+def replace_named_groups(pattern):
+ r"""
+ Find named groups in `pattern` and replace them with the group name. E.g.,
+ 1. ^(?P<a>\w+)/b/(\w+)$ ==> ^<a>/b/(\w+)$
+ 2. ^(?P<a>\w+)/b/(?P<c>\w+)/$ ==> ^<a>/b/<c>/$
+ 3. ^(?P<a>\w+)/b/(\w+) ==> ^<a>/b/(\w+)
+ 4. ^(?P<a>\w+)/b/(?P<c>\w+) ==> ^<a>/b/<c>
+ """
+ group_pattern_and_name = [
+ (pattern[start:end], match[1])
+ for start, end, match in _find_groups(pattern, _NAMED_GROUP_MATCHER)
+ ]
+ for group_pattern, group_name in group_pattern_and_name:
+ pattern = pattern.replace(group_pattern, group_name)
+ return pattern
+
+
+def replace_unnamed_groups(pattern):
+ r"""
+ Find unnamed groups in `pattern` and replace them with '<var>'. E.g.,
+ 1. ^(?P<a>\w+)/b/(\w+)$ ==> ^(?P<a>\w+)/b/<var>$
+ 2. ^(?P<a>\w+)/b/((x|y)\w+)$ ==> ^(?P<a>\w+)/b/<var>$
+ 3. ^(?P<a>\w+)/b/(\w+) ==> ^(?P<a>\w+)/b/<var>
+ 4. ^(?P<a>\w+)/b/((x|y)\w+) ==> ^(?P<a>\w+)/b/<var>
+ """
+ final_pattern, prev_end = "", None
+ for start, end, _ignored in _find_groups(pattern, _UNNAMED_GROUP_MATCHER):
+ if prev_end:
+ final_pattern += pattern[prev_end:start]
+ final_pattern += pattern[:start] + "<var>"
+ prev_end = end
+ return final_pattern + pattern[prev_end:]
+
+
+def remove_non_capturing_groups(pattern):
+ r"""
+ Find non-capturing groups in the given `pattern` and remove them, e.g.
+ 1. (?P<a>\w+)/b/(?:\w+)c(?:\w+) => (?P<a>\\w+)/b/c
+ 2. ^(?:\w+(?:\w+))a => ^a
+ 3. ^a(?:\w+)/b(?:\w+) => ^a/b
+ """
+ group_start_end_indices = _find_groups(pattern, _NON_CAPTURING_GROUP_MATCHER)
+ final_pattern, prev_end = "", None
+ for start, end, _ignored in group_start_end_indices:
+ final_pattern += pattern[prev_end:start]
+ prev_end = end
+ return final_pattern + pattern[prev_end:]
+
+
+def extract_views_from_urlpatterns(urlpatterns, base="", namespace=None):
+ """
+ Return a list of views from a list of urlpatterns.
+
+ Each object in the returned list is a 4-tuple:
+ (view_func, regex, namespace, name)
+ """
+ views = []
+ for p in urlpatterns:
+ if hasattr(p, "url_patterns"):
+ try:
+ patterns = p.url_patterns
+ except ImportError:
+ continue
+ views.extend(
+ extract_views_from_urlpatterns(
+ patterns,
+ base + str(p.pattern),
+ (namespace or []) + (p.namespace and [p.namespace] or []),
+ )
+ )
+ elif hasattr(p, "callback"):
+ try:
+ views.append((p.callback, base + str(p.pattern), namespace, p.name))
+ except ViewDoesNotExist:
+ continue
+ else:
+ raise TypeError(_("%s does not appear to be a urlpattern object") % p)
+ return views
+
+
+def simplify_regex(pattern):
+ r"""
+ Clean up urlpattern regexes into something more readable by humans. For
+ example, turn "^(?P<sport_slug>\w+)/athletes/(?P<athlete_slug>\w+)/$"
+ into "/<sport_slug>/athletes/<athlete_slug>/".
+ """
+ pattern = remove_non_capturing_groups(pattern)
+ pattern = replace_named_groups(pattern)
+ pattern = replace_unnamed_groups(pattern)
+ pattern = replace_metacharacters(pattern)
+ if not pattern.startswith("/"):
+ pattern = "/" + pattern
+ return pattern
diff --git a/tests/admin_docs/test_views.py b/tests/admin_docs/test_views.py
index bec555bd44..7dee7ae98a 100644
--- a/tests/admin_docs/test_views.py
+++ b/tests/admin_docs/test_views.py
@@ -4,13 +4,13 @@ import unittest
from django.conf import settings
from django.contrib import admin
from django.contrib.admindocs import utils, views
-from django.contrib.admindocs.views import get_return_data_type, simplify_regex
+from django.contrib.admindocs.views import get_return_data_type
from django.contrib.auth.models import Permission, User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.db import models
from django.db.models import fields
-from django.test import SimpleTestCase, modify_settings, override_settings
+from django.test import modify_settings, override_settings
from django.test.utils import captured_stderr
from django.urls import include, path, reverse
from django.utils.functional import SimpleLazyObject
@@ -627,114 +627,3 @@ class TestFieldType(unittest.TestCase):
views.get_readable_field_data_type(DescriptionLackingField()),
"Field of type: DescriptionLackingField",
)
-
-
-class AdminDocViewFunctionsTests(SimpleTestCase):
- def test_simplify_regex(self):
- tests = (
- # Named and unnamed groups.
- (r"^(?P<a>\w+)/b/(?P<c>\w+)/$", "/<a>/b/<c>/"),
- (r"^(?P<a>\w+)/b/(?P<c>\w+)$", "/<a>/b/<c>"),
- (r"^(?P<a>\w+)/b/(?P<c>\w+)", "/<a>/b/<c>"),
- (r"^(?P<a>\w+)/b/(\w+)$", "/<a>/b/<var>"),
- (r"^(?P<a>\w+)/b/(\w+)", "/<a>/b/<var>"),
- (r"^(?P<a>\w+)/b/((x|y)\w+)$", "/<a>/b/<var>"),
- (r"^(?P<a>\w+)/b/((x|y)\w+)", "/<a>/b/<var>"),
- (r"^(?P<a>(x|y))/b/(?P<c>\w+)$", "/<a>/b/<c>"),
- (r"^(?P<a>(x|y))/b/(?P<c>\w+)", "/<a>/b/<c>"),
- (r"^(?P<a>(x|y))/b/(?P<c>\w+)ab", "/<a>/b/<c>ab"),
- (r"^(?P<a>(x|y)(\(|\)))/b/(?P<c>\w+)ab", "/<a>/b/<c>ab"),
- # Non-capturing groups.
- (r"^a(?:\w+)b", "/ab"),
- (r"^a(?:(x|y))", "/a"),
- (r"^(?:\w+(?:\w+))a", "/a"),
- (r"^a(?:\w+)/b(?:\w+)", "/a/b"),
- (r"(?P<a>\w+)/b/(?:\w+)c(?:\w+)", "/<a>/b/c"),
- (r"(?P<a>\w+)/b/(\w+)/(?:\w+)c(?:\w+)", "/<a>/b/<var>/c"),
- # Single and repeated metacharacters.
- (r"^a", "/a"),
- (r"^^a", "/a"),
- (r"^^^a", "/a"),
- (r"a$", "/a"),
- (r"a$$", "/a"),
- (r"a$$$", "/a"),
- (r"a?", "/a"),
- (r"a??", "/a"),
- (r"a???", "/a"),
- (r"a*", "/a"),
- (r"a**", "/a"),
- (r"a***", "/a"),
- (r"a+", "/a"),
- (r"a++", "/a"),
- (r"a+++", "/a"),
- (r"\Aa", "/a"),
- (r"\A\Aa", "/a"),
- (r"\A\A\Aa", "/a"),
- (r"a\Z", "/a"),
- (r"a\Z\Z", "/a"),
- (r"a\Z\Z\Z", "/a"),
- (r"\ba", "/a"),
- (r"\b\ba", "/a"),
- (r"\b\b\ba", "/a"),
- (r"a\B", "/a"),
- (r"a\B\B", "/a"),
- (r"a\B\B\B", "/a"),
- # Multiple mixed metacharacters.
- (r"^a/?$", "/a/"),
- (r"\Aa\Z", "/a"),
- (r"\ba\B", "/a"),
- # Escaped single metacharacters.
- (r"\^a", r"/^a"),
- (r"\\^a", r"/\\a"),
- (r"\\\^a", r"/\\^a"),
- (r"\\\\^a", r"/\\\\a"),
- (r"\\\\\^a", r"/\\\\^a"),
- (r"a\$", r"/a$"),
- (r"a\\$", r"/a\\"),
- (r"a\\\$", r"/a\\$"),
- (r"a\\\\$", r"/a\\\\"),
- (r"a\\\\\$", r"/a\\\\$"),
- (r"a\?", r"/a?"),
- (r"a\\?", r"/a\\"),
- (r"a\\\?", r"/a\\?"),
- (r"a\\\\?", r"/a\\\\"),
- (r"a\\\\\?", r"/a\\\\?"),
- (r"a\*", r"/a*"),
- (r"a\\*", r"/a\\"),
- (r"a\\\*", r"/a\\*"),
- (r"a\\\\*", r"/a\\\\"),
- (r"a\\\\\*", r"/a\\\\*"),
- (r"a\+", r"/a+"),
- (r"a\\+", r"/a\\"),
- (r"a\\\+", r"/a\\+"),
- (r"a\\\\+", r"/a\\\\"),
- (r"a\\\\\+", r"/a\\\\+"),
- (r"\\Aa", r"/\Aa"),
- (r"\\\Aa", r"/\\a"),
- (r"\\\\Aa", r"/\\\Aa"),
- (r"\\\\\Aa", r"/\\\\a"),
- (r"\\\\\\Aa", r"/\\\\\Aa"),
- (r"a\\Z", r"/a\Z"),
- (r"a\\\Z", r"/a\\"),
- (r"a\\\\Z", r"/a\\\Z"),
- (r"a\\\\\Z", r"/a\\\\"),
- (r"a\\\\\\Z", r"/a\\\\\Z"),
- # Escaped mixed metacharacters.
- (r"^a\?$", r"/a?"),
- (r"^a\\?$", r"/a\\"),
- (r"^a\\\?$", r"/a\\?"),
- (r"^a\\\\?$", r"/a\\\\"),
- (r"^a\\\\\?$", r"/a\\\\?"),
- # Adjacent escaped metacharacters.
- (r"^a\?\$", r"/a?$"),
- (r"^a\\?\\$", r"/a\\\\"),
- (r"^a\\\?\\\$", r"/a\\?\\$"),
- (r"^a\\\\?\\\\$", r"/a\\\\\\\\"),
- (r"^a\\\\\?\\\\\$", r"/a\\\\?\\\\$"),
- # Complex examples with metacharacters and (un)named groups.
- (r"^\b(?P<slug>\w+)\B/(\w+)?", "/<slug>/<var>"),
- (r"^\A(?P<slug>\w+)\Z", "/<slug>"),
- )
- for pattern, output in tests:
- with self.subTest(pattern=pattern):
- self.assertEqual(simplify_regex(pattern), output)
diff --git a/tests/urlpatterns/tests.py b/tests/urlpatterns/tests.py
index 8636ef15f9..ba6510a2eb 100644
--- a/tests/urlpatterns/tests.py
+++ b/tests/urlpatterns/tests.py
@@ -14,6 +14,7 @@ from django.urls import (
reverse,
)
from django.urls.converters import REGISTERED_CONVERTERS, IntConverter
+from django.urls.utils import simplify_regex
from django.views import View
from .converters import Base64Converter, DynamicConverter
@@ -436,3 +437,114 @@ class ConversionExceptionTests(SimpleTestCase):
with self.assertRaisesMessage(TypeError, "This type error propagates."):
reverse("dynamic", kwargs={"value": object()})
+
+
+class SimplifyRegexTests(SimpleTestCase):
+ def test_simplify_regex(self):
+ tests = (
+ # Named and unnamed groups.
+ (r"^(?P<a>\w+)/b/(?P<c>\w+)/$", "/<a>/b/<c>/"),
+ (r"^(?P<a>\w+)/b/(?P<c>\w+)$", "/<a>/b/<c>"),
+ (r"^(?P<a>\w+)/b/(?P<c>\w+)", "/<a>/b/<c>"),
+ (r"^(?P<a>\w+)/b/(\w+)$", "/<a>/b/<var>"),
+ (r"^(?P<a>\w+)/b/(\w+)", "/<a>/b/<var>"),
+ (r"^(?P<a>\w+)/b/((x|y)\w+)$", "/<a>/b/<var>"),
+ (r"^(?P<a>\w+)/b/((x|y)\w+)", "/<a>/b/<var>"),
+ (r"^(?P<a>(x|y))/b/(?P<c>\w+)$", "/<a>/b/<c>"),
+ (r"^(?P<a>(x|y))/b/(?P<c>\w+)", "/<a>/b/<c>"),
+ (r"^(?P<a>(x|y))/b/(?P<c>\w+)ab", "/<a>/b/<c>ab"),
+ (r"^(?P<a>(x|y)(\(|\)))/b/(?P<c>\w+)ab", "/<a>/b/<c>ab"),
+ # Non-capturing groups.
+ (r"^a(?:\w+)b", "/ab"),
+ (r"^a(?:(x|y))", "/a"),
+ (r"^(?:\w+(?:\w+))a", "/a"),
+ (r"^a(?:\w+)/b(?:\w+)", "/a/b"),
+ (r"(?P<a>\w+)/b/(?:\w+)c(?:\w+)", "/<a>/b/c"),
+ (r"(?P<a>\w+)/b/(\w+)/(?:\w+)c(?:\w+)", "/<a>/b/<var>/c"),
+ # Single and repeated metacharacters.
+ (r"^a", "/a"),
+ (r"^^a", "/a"),
+ (r"^^^a", "/a"),
+ (r"a$", "/a"),
+ (r"a$$", "/a"),
+ (r"a$$$", "/a"),
+ (r"a?", "/a"),
+ (r"a??", "/a"),
+ (r"a???", "/a"),
+ (r"a*", "/a"),
+ (r"a**", "/a"),
+ (r"a***", "/a"),
+ (r"a+", "/a"),
+ (r"a++", "/a"),
+ (r"a+++", "/a"),
+ (r"\Aa", "/a"),
+ (r"\A\Aa", "/a"),
+ (r"\A\A\Aa", "/a"),
+ (r"a\Z", "/a"),
+ (r"a\Z\Z", "/a"),
+ (r"a\Z\Z\Z", "/a"),
+ (r"\ba", "/a"),
+ (r"\b\ba", "/a"),
+ (r"\b\b\ba", "/a"),
+ (r"a\B", "/a"),
+ (r"a\B\B", "/a"),
+ (r"a\B\B\B", "/a"),
+ # Multiple mixed metacharacters.
+ (r"^a/?$", "/a/"),
+ (r"\Aa\Z", "/a"),
+ (r"\ba\B", "/a"),
+ # Escaped single metacharacters.
+ (r"\^a", r"/^a"),
+ (r"\\^a", r"/\\a"),
+ (r"\\\^a", r"/\\^a"),
+ (r"\\\\^a", r"/\\\\a"),
+ (r"\\\\\^a", r"/\\\\^a"),
+ (r"a\$", r"/a$"),
+ (r"a\\$", r"/a\\"),
+ (r"a\\\$", r"/a\\$"),
+ (r"a\\\\$", r"/a\\\\"),
+ (r"a\\\\\$", r"/a\\\\$"),
+ (r"a\?", r"/a?"),
+ (r"a\\?", r"/a\\"),
+ (r"a\\\?", r"/a\\?"),
+ (r"a\\\\?", r"/a\\\\"),
+ (r"a\\\\\?", r"/a\\\\?"),
+ (r"a\*", r"/a*"),
+ (r"a\\*", r"/a\\"),
+ (r"a\\\*", r"/a\\*"),
+ (r"a\\\\*", r"/a\\\\"),
+ (r"a\\\\\*", r"/a\\\\*"),
+ (r"a\+", r"/a+"),
+ (r"a\\+", r"/a\\"),
+ (r"a\\\+", r"/a\\+"),
+ (r"a\\\\+", r"/a\\\\"),
+ (r"a\\\\\+", r"/a\\\\+"),
+ (r"\\Aa", r"/\Aa"),
+ (r"\\\Aa", r"/\\a"),
+ (r"\\\\Aa", r"/\\\Aa"),
+ (r"\\\\\Aa", r"/\\\\a"),
+ (r"\\\\\\Aa", r"/\\\\\Aa"),
+ (r"a\\Z", r"/a\Z"),
+ (r"a\\\Z", r"/a\\"),
+ (r"a\\\\Z", r"/a\\\Z"),
+ (r"a\\\\\Z", r"/a\\\\"),
+ (r"a\\\\\\Z", r"/a\\\\\Z"),
+ # Escaped mixed metacharacters.
+ (r"^a\?$", r"/a?"),
+ (r"^a\\?$", r"/a\\"),
+ (r"^a\\\?$", r"/a\\?"),
+ (r"^a\\\\?$", r"/a\\\\"),
+ (r"^a\\\\\?$", r"/a\\\\?"),
+ # Adjacent escaped metacharacters.
+ (r"^a\?\$", r"/a?$"),
+ (r"^a\\?\\$", r"/a\\\\"),
+ (r"^a\\\?\\\$", r"/a\\?\\$"),
+ (r"^a\\\\?\\\\$", r"/a\\\\\\\\"),
+ (r"^a\\\\\?\\\\\$", r"/a\\\\?\\\\$"),
+ # Complex examples with metacharacters and (un)named groups.
+ (r"^\b(?P<slug>\w+)\B/(\w+)?", "/<slug>/<var>"),
+ (r"^\A(?P<slug>\w+)\Z", "/<slug>"),
+ )
+ for pattern, output in tests:
+ with self.subTest(pattern=pattern):
+ self.assertEqual(simplify_regex(pattern), output)