diff options
| author | Chris Rose <offline@offby1.net> | 2026-05-24 09:59:38 -0700 |
|---|---|---|
| committer | Jacob Walls <jacobtylerwalls@gmail.com> | 2026-06-01 15:24:49 -0400 |
| commit | 804660d685a5abd49fc66ba20c98d1a523f28f9f (patch) | |
| tree | 0ff0574ddeb992f9590cd6831671404bf2637d5e | |
| parent | 2715b7f499ef52217241560705d47fb5e0079620 (diff) | |
Refs #28800 -- Lifted some url functions from admindocs into urls.utils.
| -rw-r--r-- | django/contrib/admindocs/utils.py | 96 | ||||
| -rw-r--r-- | django/contrib/admindocs/views.py | 54 | ||||
| -rw-r--r-- | django/urls/utils.py | 140 | ||||
| -rw-r--r-- | tests/admin_docs/test_views.py | 115 | ||||
| -rw-r--r-- | tests/urlpatterns/tests.py | 112 |
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) |
