From a745c92eb3f9a633a2b98e0e648f774448c47d3f Mon Sep 17 00:00:00 2001 From: Chris Rose Date: Sun, 24 May 2026 09:59:38 -0700 Subject: [PATCH 1/3] Refs #28800 -- Lifted some url functions from admindocs into urls.utils. --- django/contrib/admindocs/utils.py | 96 ++------------------ django/contrib/admindocs/views.py | 54 +----------- django/urls/utils.py | 140 ++++++++++++++++++++++++++++++ tests/admin_docs/test_views.py | 115 +----------------------- 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 4d9403a6f7ee..4a9acc9548f9 100644 --- a/django/contrib/admindocs/utils.py +++ b/django/contrib/admindocs/utils.py @@ -6,7 +6,11 @@ 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 @@ def default_reference_role( 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"((?:^|(?(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\w+)/b/(\w+)$ ==> ^/b/(\w+)$ - 2. ^(?P\w+)/b/(?P\w+)/$ ==> ^/b//$ - 3. ^(?P\w+)/b/(\w+) ==> ^/b/(\w+) - 4. ^(?P\w+)/b/(?P\w+) ==> ^/b/ - """ - 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 ''. E.g., - 1. ^(?P\w+)/b/(\w+)$ ==> ^(?P\w+)/b/$ - 2. ^(?P\w+)/b/((x|y)\w+)$ ==> ^(?P\w+)/b/$ - 3. ^(?P\w+)/b/(\w+) ==> ^(?P\w+)/b/ - 4. ^(?P\w+)/b/((x|y)\w+) ==> ^(?P\w+)/b/ - """ - 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] + "" - 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\w+)/b/(?:\w+)c(?:\w+) => (?P\\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("

", "").replace("

", "")) diff --git a/django/contrib/admindocs/views.py b/django/contrib/admindocs/views.py index 0c4ece29feb2..6a8453b29270 100644 --- a/django/contrib/admindocs/views.py +++ b/django/contrib/admindocs/views.py @@ -7,22 +7,16 @@ 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\w+)/athletes/(?P\w+)/$" - into "//athletes//". - """ - 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 b5054b163cd9..5bf3988361ce 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"((?:^|(?(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
\w+)/b/(\w+)$ ==> ^/b/(\w+)$ + 2. ^(?P\w+)/b/(?P\w+)/$ ==> ^/b//$ + 3. ^(?P\w+)/b/(\w+) ==> ^/b/(\w+) + 4. ^(?P\w+)/b/(?P\w+) ==> ^/b/ + """ + 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 ''. E.g., + 1. ^(?P\w+)/b/(\w+)$ ==> ^(?P\w+)/b/$ + 2. ^(?P\w+)/b/((x|y)\w+)$ ==> ^(?P\w+)/b/$ + 3. ^(?P\w+)/b/(\w+) ==> ^(?P\w+)/b/ + 4. ^(?P\w+)/b/((x|y)\w+) ==> ^(?P\w+)/b/ + """ + 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] + "" + 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\w+)/b/(?:\w+)c(?:\w+) => (?P\\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\w+)/athletes/(?P\w+)/$" + into "//athletes//". + """ + 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 bec555bd442f..7dee7ae98a1f 100644 --- a/tests/admin_docs/test_views.py +++ b/tests/admin_docs/test_views.py @@ -4,13 +4,13 @@ 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 @@ def test_custom_fields(self): 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\w+)/b/(?P\w+)/$", "//b//"), - (r"^(?P\w+)/b/(?P\w+)$", "//b/"), - (r"^(?P\w+)/b/(?P\w+)", "//b/"), - (r"^(?P\w+)/b/(\w+)$", "//b/"), - (r"^(?P\w+)/b/(\w+)", "//b/"), - (r"^(?P\w+)/b/((x|y)\w+)$", "//b/"), - (r"^(?P\w+)/b/((x|y)\w+)", "//b/"), - (r"^(?P(x|y))/b/(?P\w+)$", "//b/"), - (r"^(?P(x|y))/b/(?P\w+)", "//b/"), - (r"^(?P(x|y))/b/(?P\w+)ab", "//b/ab"), - (r"^(?P(x|y)(\(|\)))/b/(?P\w+)ab", "//b/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\w+)/b/(?:\w+)c(?:\w+)", "//b/c"), - (r"(?P\w+)/b/(\w+)/(?:\w+)c(?:\w+)", "//b//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\w+)\B/(\w+)?", "//"), - (r"^\A(?P\w+)\Z", "/"), - ) - 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 8636ef15f9b5..ba6510a2ebf3 100644 --- a/tests/urlpatterns/tests.py +++ b/tests/urlpatterns/tests.py @@ -14,6 +14,7 @@ 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 @@ def raises_type_error(value): 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\w+)/b/(?P\w+)/$", "//b//"), + (r"^(?P\w+)/b/(?P\w+)$", "//b/"), + (r"^(?P\w+)/b/(?P\w+)", "//b/"), + (r"^(?P\w+)/b/(\w+)$", "//b/"), + (r"^(?P\w+)/b/(\w+)", "//b/"), + (r"^(?P\w+)/b/((x|y)\w+)$", "//b/"), + (r"^(?P\w+)/b/((x|y)\w+)", "//b/"), + (r"^(?P(x|y))/b/(?P\w+)$", "//b/"), + (r"^(?P(x|y))/b/(?P\w+)", "//b/"), + (r"^(?P(x|y))/b/(?P\w+)ab", "//b/ab"), + (r"^(?P(x|y)(\(|\)))/b/(?P\w+)ab", "//b/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\w+)/b/(?:\w+)c(?:\w+)", "//b/c"), + (r"(?P\w+)/b/(\w+)/(?:\w+)c(?:\w+)", "//b//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\w+)\B/(\w+)?", "//"), + (r"^\A(?P\w+)\Z", "/"), + ) + for pattern, output in tests: + with self.subTest(pattern=pattern): + self.assertEqual(simplify_regex(pattern), output) From 1520b771d66ebe5dc1914e2b7972673ac47ccffa Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Tue, 26 May 2026 12:42:39 -0400 Subject: [PATCH 2/3] Refs #28800 -- Handled escaped literals in simplify_regex(). --- django/urls/utils.py | 6 ++++++ tests/urlpatterns/tests.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/django/urls/utils.py b/django/urls/utils.py index 5bf3988361ce..59a0ae6e9566 100644 --- a/django/urls/utils.py +++ b/django/urls/utils.py @@ -73,6 +73,7 @@ def get_mod_func(callback): _NAMED_GROUP_MATCHER = _lazy_re_compile(r"\(\?P(<\w+>)") _UNNAMED_GROUP_MATCHER = _lazy_re_compile(r"\(") _NON_CAPTURING_GROUP_MATCHER = _lazy_re_compile(r"\(\?\:") +_LITERAL_ESCAPE_RE = _lazy_re_compile(r"\\([./()_-])") def replace_metacharacters(pattern): @@ -160,6 +161,10 @@ def remove_non_capturing_groups(pattern): return final_pattern + pattern[prev_end:] +def unescape_literals(pattern): + return _LITERAL_ESCAPE_RE.sub(r"\1", pattern) + + def extract_views_from_urlpatterns(urlpatterns, base="", namespace=None): """ Return a list of views from a list of urlpatterns. @@ -201,6 +206,7 @@ def simplify_regex(pattern): pattern = replace_named_groups(pattern) pattern = replace_unnamed_groups(pattern) pattern = replace_metacharacters(pattern) + pattern = unescape_literals(pattern) if not pattern.startswith("/"): pattern = "/" + pattern return pattern diff --git a/tests/urlpatterns/tests.py b/tests/urlpatterns/tests.py index ba6510a2ebf3..32657e58d44d 100644 --- a/tests/urlpatterns/tests.py +++ b/tests/urlpatterns/tests.py @@ -544,6 +544,12 @@ def test_simplify_regex(self): # Complex examples with metacharacters and (un)named groups. (r"^\b(?P\w+)\B/(\w+)?", "//"), (r"^\A(?P\w+)\Z", "/"), + # Single escaped literals. + (r"\/well-known", "/well-known"), + (r"\.well-known", "/.well-known"), + (r"\-well-known", "/-well-known"), + (r"\_well-known", "/_well-known"), + (r"\(well-known\)", "/(well-known)"), ) for pattern, output in tests: with self.subTest(pattern=pattern): From 082680efbef79f9b049d181916dc527900de5483 Mon Sep 17 00:00:00 2001 From: Chris Rose Date: Mon, 18 May 2026 10:49:34 -0700 Subject: [PATCH 3/3] Fixed #28800 -- Added a listurls management command. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ülgen Sarıkavak --- AUTHORS | 2 + .../commands/LICENSE.django-extensions | 19 ++ django/core/management/commands/listurls.py | 186 ++++++++++++ django/utils/termcolors.py | 9 + docs/ref/django-admin.txt | 22 ++ docs/releases/6.2.txt | 3 +- tests/admin_scripts/app_with_urls/__init__.py | 0 .../admin_scripts/app_with_urls/root_urls.py | 25 ++ tests/admin_scripts/app_with_urls/urls_cbv.py | 17 ++ .../app_with_urls/urls_namespaced.py | 17 ++ .../admin_scripts/app_with_urls/urls_nons.py | 17 ++ tests/admin_scripts/app_with_urls/views.py | 21 ++ tests/admin_scripts/tests.py | 285 ++++++++++++++++++ 13 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 django/core/management/commands/LICENSE.django-extensions create mode 100644 django/core/management/commands/listurls.py create mode 100644 tests/admin_scripts/app_with_urls/__init__.py create mode 100644 tests/admin_scripts/app_with_urls/root_urls.py create mode 100644 tests/admin_scripts/app_with_urls/urls_cbv.py create mode 100644 tests/admin_scripts/app_with_urls/urls_namespaced.py create mode 100644 tests/admin_scripts/app_with_urls/urls_nons.py create mode 100644 tests/admin_scripts/app_with_urls/views.py diff --git a/AUTHORS b/AUTHORS index a9403842b54e..36914d29501c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -229,6 +229,7 @@ answer newbie questions, and generally made Django that much better: Chris Jerdonek Chris Jones Chris Lamb + Chris Rose Chris Streeter Christian Barcenas Christian Metts @@ -1078,6 +1079,7 @@ answer newbie questions, and generally made Django that much better: Tyson Clugg Tyson Tate Unai Zalakain + Ülgen Sarıkavak Valentina Mukhamedzhanova valtron Varun Kasyap Pentamaraju diff --git a/django/core/management/commands/LICENSE.django-extensions b/django/core/management/commands/LICENSE.django-extensions new file mode 100644 index 000000000000..279c6daa9156 --- /dev/null +++ b/django/core/management/commands/LICENSE.django-extensions @@ -0,0 +1,19 @@ +Copyright (c) 2007 Michael Trier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/django/core/management/commands/listurls.py b/django/core/management/commands/listurls.py new file mode 100644 index 000000000000..aae1d947f987 --- /dev/null +++ b/django/core/management/commands/listurls.py @@ -0,0 +1,186 @@ +# Portions of this code are derived from django-extensions (MIT): +# https://github.com/django-extensions/django-extensions +# See LICENSE.django-extensions in this directory. + +import json +from collections import namedtuple +from importlib import import_module + +from django.conf import settings +from django.core.management import color +from django.core.management.base import BaseCommand, CommandError, CommandParser +from django.urls.utils import ( + extract_views_from_urlpatterns, + simplify_regex, +) + +FORMATS = ( + "aligned", + "verbose", + "json", +) + +COLORLESS_FORMATS = ("json",) + +URLPattern = namedtuple("URLPattern", ["route", "view", "name"]) + + +class Command(BaseCommand): + help = "List URL patterns in the project with optional filtering by prefixes." + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.style = color.color_style() + + def add_arguments(self, parser: CommandParser): + super().add_arguments(parser) + + parser.add_argument( + "--unsorted", + "-u", + action="store_true", + dest="unsorted", + help="Show URLs without sorting them alphabetically.", + ) + parser.add_argument( + "--prefix", + "-p", + dest="prefixes", + help="Only list URLs with these prefixes.", + nargs="+", + ) + parser.add_argument( + "--format", + "-f", + choices=FORMATS, + default="aligned", + dest="format", + help="Formatting style of the output", + ) + + def handle(self, *args, **options): + prefixes = options["prefixes"] + url_patterns = self.get_url_patterns(prefixes=prefixes) + if not url_patterns: + raise CommandError("There are no URL patterns that match given prefixes") + + unsorted = options["unsorted"] + no_color = options["no_color"] + format = options["format"] + if not unsorted: + url_patterns.sort() + + self.is_color_enabled = ( + color.supports_color() + and (not no_color) + and (format not in COLORLESS_FORMATS) + ) + if self.is_color_enabled: + url_patterns = self.apply_color(url_patterns=url_patterns) + + url_patterns = self.apply_format(url_patterns=url_patterns, format=format) + return url_patterns + + @classmethod + def get_url_patterns(cls, prefixes=None): + """ + Returns a list of URL patterns in the project with given prefixes. + + Each object in the returned list is a tuple[str, str, str]: + (route, view, name). + """ + url_patterns = [] + urlconf = import_module(settings.ROOT_URLCONF) + + for view_func, regex, namespace, name in extract_views_from_urlpatterns( + urlconf.urlpatterns + ): + route = simplify_regex(regex) + + if hasattr(view_func, "view_class"): + view_func = view_func.view_class + + view = "{}.{}".format( + view_func.__module__, + getattr(view_func, "__name__", view_func.__class__.__name__), + ) + namespace_list = namespace or [] + name = ":".join(namespace_list + [name]) if name else "" + + pattern = URLPattern(route, view, name) + if not prefixes or any( + pattern.route.startswith(prefix) for prefix in prefixes + ): + url_patterns.append(pattern) + + return url_patterns + + def apply_color(self, url_patterns): + colored_url_patterns = [] + + for url_pattern in url_patterns: + route = self.style.ADMIN_DATA(url_pattern.route) + + module_path, module_name = url_pattern.view.rsplit(".", 1) + module_name = self.style.ADMIN_HIGHLIGHT(module_name) + view = f"{module_path}.{module_name}" + + if name := url_pattern.name: + namespace, name = name.rsplit(":", 1) if ":" in name else ("", name) + name = self.style.ADMIN_HIGHLIGHT(name) + name = f"{namespace}:{name}" if namespace else name + + colored_url_patterns.append((route, view, name)) + + return colored_url_patterns + + def apply_format(self, url_patterns, format): + format_method_name = f"format_{format.replace('-', '_')}" + format_method = getattr(self, format_method_name) + return format_method(url_patterns) + + def format_aligned(self, url_patterns): + widths = [] + margin = 2 + for columns in zip(*url_patterns, strict=False): + widths.append(len(max(columns, key=len)) + margin) + + lines = [] + for row in url_patterns: + line = "".join( + cdata.ljust(width) for width, cdata in zip(widths, row, strict=False) + ) + lines.append(line) + + return "\n".join(lines) + + def format_verbose(self, url_patterns): + separator = "-" * 20 + "\n" + lines = [] + for route, view, name in url_patterns: + route_title = "Route:" + view_title = "View:" + name_title = "Name:" + if self.is_color_enabled: + route_title = self.style.ADMIN_HEADER(route_title) + view_title = self.style.ADMIN_HEADER(view_title) + name_title = self.style.ADMIN_HEADER(name_title) + + route_str = f"{route_title} {route}" + view_str = f"{view_title} {view}" + name_str = f"{name_title} {name}" if name else "" + parts = ( + route_str, + view_str, + name_str, + separator, + ) + + lines.append("\n".join([part for part in parts if part])) + + return "\n".join(lines) + + def format_json(self, url_patterns): + url_pattern_dicts = [url_pattern._asdict() for url_pattern in url_patterns] + return json.dumps(url_pattern_dicts, indent=2) diff --git a/django/utils/termcolors.py b/django/utils/termcolors.py index aeef02f1b0df..9cbeba94c18d 100644 --- a/django/utils/termcolors.py +++ b/django/utils/termcolors.py @@ -97,6 +97,9 @@ def make_style(opts=(), **kwargs): "HTTP_SERVER_ERROR": {}, "MIGRATE_HEADING": {}, "MIGRATE_LABEL": {}, + "ADMIN_HEADER": {}, + "ADMIN_DATA": {}, + "ADMIN_HIGHLIGHT": {}, }, DARK_PALETTE: { "ERROR": {"fg": "red", "opts": ("bold",)}, @@ -116,6 +119,9 @@ def make_style(opts=(), **kwargs): "HTTP_SERVER_ERROR": {"fg": "magenta", "opts": ("bold",)}, "MIGRATE_HEADING": {"fg": "cyan", "opts": ("bold",)}, "MIGRATE_LABEL": {"opts": ("bold",)}, + "ADMIN_HEADER": {"fg": "cyan", "opts": ("bold",)}, + "ADMIN_DATA": {"opts": ("bold",)}, + "ADMIN_HIGHLIGHT": {"fg": "yellow", "opts": ("bold",)}, }, LIGHT_PALETTE: { "ERROR": {"fg": "red", "opts": ("bold",)}, @@ -135,6 +141,9 @@ def make_style(opts=(), **kwargs): "HTTP_SERVER_ERROR": {"fg": "magenta", "opts": ("bold",)}, "MIGRATE_HEADING": {"fg": "cyan", "opts": ("bold",)}, "MIGRATE_LABEL": {"opts": ("bold",)}, + "ADMIN_HEADER": {"fg": "cyan", "opts": ("bold",)}, + "ADMIN_DATA": {"opts": ("bold",)}, + "ADMIN_HIGHLIGHT": {"fg": "yellow", "opts": ("bold",)}, }, } DEFAULT_PALETTE = DARK_PALETTE diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index adbd2465a719..5478d3ccd6f7 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -499,6 +499,28 @@ Only support for PostgreSQL is implemented. If this option is provided, models are also created for database views. +``listurls`` +------------ + +.. django-admin:: listurls + +.. versionadded:: 6.2 + +List URL patterns in the project with optional filtering by prefixes. + +.. django-admin-option:: --unsorted, -u + +Lists URLs with the original ordering, same order as found in url patterns. + +.. django-admin-option:: --prefix, -p [prefix ...] + +Filters URLs by given prefixes. + +.. django-admin-option:: --format, -f {aligned,verbose,json} + +Specifies the output format. Available values are ``aligned``, ``verbose``, +``json``. Default is ``aligned``. + ``loaddata`` ------------ diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index ba9396313d71..f739d73f1974 100644 --- a/docs/releases/6.2.txt +++ b/docs/releases/6.2.txt @@ -173,7 +173,8 @@ Logging Management Commands ~~~~~~~~~~~~~~~~~~~ -* ... +* The new :djadmin:`listurls` command lists the URLs in the application, + including the view class or function (and name, if present). Migrations ~~~~~~~~~~ diff --git a/tests/admin_scripts/app_with_urls/__init__.py b/tests/admin_scripts/app_with_urls/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/admin_scripts/app_with_urls/root_urls.py b/tests/admin_scripts/app_with_urls/root_urls.py new file mode 100644 index 000000000000..df14128138b0 --- /dev/null +++ b/tests/admin_scripts/app_with_urls/root_urls.py @@ -0,0 +1,25 @@ +from django.urls import include, path, re_path + + +def dummy_view(request): ... + + +urlpatterns = [ + path( + route="nons/", + view=include("admin_scripts.app_with_urls.urls_nons"), + ), + path( + route="namespaced/", + view=include("admin_scripts.app_with_urls.urls_namespaced", namespace="ns"), + ), + path( + route="cbv/", + view=include("admin_scripts.app_with_urls.urls_cbv"), + ), + re_path( + r"^\.well-known/openid-configuration/?$", + dummy_view, + name="oidc-connect-discovery-info", + ), +] diff --git a/tests/admin_scripts/app_with_urls/urls_cbv.py b/tests/admin_scripts/app_with_urls/urls_cbv.py new file mode 100644 index 000000000000..ec4740f5b90a --- /dev/null +++ b/tests/admin_scripts/app_with_urls/urls_cbv.py @@ -0,0 +1,17 @@ +from django.urls import path + +from . import views + +app_name = "app_with_urls_cbv" + +urlpatterns = [ + path( + route="unnamed", + view=views.CBV.as_view(), + ), + path( + route="named", + view=views.CBV.as_view(), + name="named", + ), +] diff --git a/tests/admin_scripts/app_with_urls/urls_namespaced.py b/tests/admin_scripts/app_with_urls/urls_namespaced.py new file mode 100644 index 000000000000..a993271c7945 --- /dev/null +++ b/tests/admin_scripts/app_with_urls/urls_namespaced.py @@ -0,0 +1,17 @@ +from django.urls import path + +from . import views + +app_name = "app_with_urls" + +urlpatterns = [ + path( + route="unnamed", + view=views.view_func_namespaced_unnamed, + ), + path( + route="named", + view=views.view_func_namespaced_named, + name="named", + ), +] diff --git a/tests/admin_scripts/app_with_urls/urls_nons.py b/tests/admin_scripts/app_with_urls/urls_nons.py new file mode 100644 index 000000000000..e957d09fa272 --- /dev/null +++ b/tests/admin_scripts/app_with_urls/urls_nons.py @@ -0,0 +1,17 @@ +from django.urls import path + +from . import views + +app_name = "app_with_urls" + +urlpatterns = [ + path( + route="unnamed", + view=views.view_func_nons_unnamed, + ), + path( + route="named", + view=views.view_func_nons_named, + name="named", + ), +] diff --git a/tests/admin_scripts/app_with_urls/views.py b/tests/admin_scripts/app_with_urls/views.py new file mode 100644 index 000000000000..64035868d4d2 --- /dev/null +++ b/tests/admin_scripts/app_with_urls/views.py @@ -0,0 +1,21 @@ +from django.views.generic import ListView + + +def view_func_namespaced_unnamed(request): + pass + + +def view_func_namespaced_named(request): + pass + + +def view_func_nons_unnamed(request): + pass + + +def view_func_nons_named(request): + pass + + +class CBV(ListView): + pass diff --git a/tests/admin_scripts/tests.py b/tests/admin_scripts/tests.py index 914f54720ce5..2d5ca986651a 100644 --- a/tests/admin_scripts/tests.py +++ b/tests/admin_scripts/tests.py @@ -4,6 +4,7 @@ DJANGO_SETTINGS_MODULE and default settings.py files. """ +import json import os import re import shutil @@ -30,6 +31,7 @@ execute_from_command_line, ) from django.core.management.base import LabelCommand, SystemCheckError +from django.core.management.commands.listurls import Command as ListurlsCommand from django.core.management.commands.loaddata import Command as LoaddataCommand from django.core.management.commands.runserver import Command as RunserverCommand from django.core.management.commands.testserver import Command as TestserverCommand @@ -3313,6 +3315,289 @@ def test_pks_parsing(self): self.assertNoOutput(out) +@override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls") +class Listurls(AdminScriptTestCase): + def test_default(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls"] + out, err = self.run_manage(args) + + self.assertNoOutput(err) + + # Check route, view and (if defined) name for each URL. + self.assertOutput(out, "/namespaced/named") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_named", + ) + self.assertOutput(out, "ns:named") + + self.assertOutput(out, "/namespaced/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed", + ) + + self.assertOutput(out, "/nons/named") + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + self.assertOutput(out, "app_with_urls:named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + def test_urls_with_metachars(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "--prefix", "/.well-known"] + out, err = self.run_manage(args) + + self.assertOutput(out, "/.well-known/openid-configuration/") + self.assertNoOutput(err) + + def test_cbv_formatting(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "--prefix", "/cbv"] + out, err = self.run_manage(args) + + self.assertOutput(out, "/cbv/named") + self.assertOutput(out, "admin_scripts.app_with_urls.views.CBV") + self.assertOutput(out, "app_with_urls_cbv:named") + self.assertNoOutput(err) + + def test_aligned(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "aligned"] + out, err = self.run_manage(args) + self.assertNoOutput(err) + + # Check route, view and (if defined) name for each URL. + self.assertOutput(out, "/namespaced/named") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_named", + ) + self.assertOutput(out, "ns:named") + + self.assertOutput(out, "/namespaced/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed", + ) + + self.assertOutput(out, "/nons/named") + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + self.assertOutput(out, "app_with_urls:named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + def test_verbose(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "verbose"] + out, err = self.run_manage(args) + self.assertNoOutput(err) + + self.assertOutput(out, "Route:") + self.assertOutput(out, "View:") + self.assertOutput(out, "Name:") + self.assertOutput(out, "-" * 20) + + self.assertOutput(out, "/namespaced/named") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_namespaced_named", + ) + self.assertOutput(out, "ns:named") + + self.assertOutput(out, "/namespaced/unnamed") + self.assertOutput( + out, "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed" + ) + + self.assertOutput(out, "/nons/named") + self.assertOutput(out, "app_with_urls:named") + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + def test_json(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "json"] + out, err = self.run_manage(args) + self.assertNoOutput(err) + + json.loads(out) + + self.assertOutput(out, '"route": "/namespaced/named"') + self.assertOutput( + out, + '"view": "admin_scripts.app_with_urls.views.view_func_namespaced_named"', + ) + self.assertOutput(out, '"name": "ns:named"') + + self.assertOutput(out, '"route": "/namespaced/unnamed"') + self.assertOutput( + out, + '"view": "admin_scripts.app_with_urls.views.view_func_namespaced_unnamed"', + ) + + self.assertOutput(out, '"route": "/nons/named"') + self.assertOutput(out, "admin_scripts.app_with_urls.views.view_func_nons_named") + self.assertOutput(out, "app_with_urls:named") + + self.assertOutput(out, "/nons/unnamed") + self.assertOutput( + out, + "admin_scripts.app_with_urls.views.view_func_nons_unnamed", + ) + + def test_unsorted(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + # JSON format is the easiest to parse and test. + args = ["listurls", "-f", "json", "--unsorted"] + out, err = self.run_manage(args) + url_patterns = json.loads(out) + + self.assertNotEqual( + url_patterns, + sorted(url_patterns, key=lambda u: u["route"]), + ) + + def test_aligned_with_color_enabled(self): + out = StringIO() + err = StringIO() + + with mock.patch( + "django.core.management.color.supports_color", lambda *args: True + ): + command = ListurlsCommand(stdout=out, stderr=err) + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + call_command(command, format="aligned") + + self.assertIn(command.style.ADMIN_DATA("/namespaced/named"), out.getvalue()) + self.assertIn( + command.style.ADMIN_HIGHLIGHT("view_func_namespaced_named"), out.getvalue() + ) + self.assertIn(command.style.ADMIN_HIGHLIGHT("named"), out.getvalue()) + + def test_aligned_with_color_suppressed(self): + out = StringIO() + err = StringIO() + + with mock.patch( + "django.core.management.color.supports_color", lambda *args: True + ): + command = ListurlsCommand(stdout=out, stderr=err) + call_command(command, format="aligned", no_color=True) + + self.assertIn("/namespaced/named", out.getvalue()) + + # There should be no escape codes in the output. + self.assertNotIn("\x1b", out.getvalue()) + + def test_verbose_with_color_enabled(self): + out = StringIO() + err = StringIO() + + with mock.patch( + "django.core.management.color.supports_color", lambda *args: True + ): + command = ListurlsCommand(stdout=out, stderr=err) + call_command(command, format="verbose") + + self.assertIn(command.style.ADMIN_DATA("/namespaced/named"), out.getvalue()) + self.assertIn( + command.style.ADMIN_HIGHLIGHT("view_func_namespaced_named"), out.getvalue() + ) + self.assertIn(command.style.ADMIN_HIGHLIGHT("named"), out.getvalue()) + for header in ["Route", "View", "Name"]: + with self.subTest(header=header): + self.assertIn(command.style.ADMIN_HEADER(f"{header}:"), out.getvalue()) + + def test_verbose_with_color_suppressed(self): + out = StringIO() + err = StringIO() + + with mock.patch( + "django.core.management.color.supports_color", lambda *args: True + ): + command = ListurlsCommand(stdout=out, stderr=err) + call_command(command, format="verbose", no_color=True) + + self.assertIn("/namespaced/named", out.getvalue()) + + # There should be no escape codes in the output. + self.assertNotIn("\x1b", out.getvalue()) + + @override_settings(ROOT_URLCONF="urls") + def test_no_urls(self): + self.write_settings("settings.py") + + args = ["listurls"] + out, err = self.run_manage(args) + + self.assertOutput(err, "There are no URL patterns that match given prefixes") + self.assertNoOutput(out) + + def test_prefixes(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-p", "/namespaced"] + out, err = self.run_manage(args) + self.assertNoOutput(err) + + self.assertOutput(out, "/namespaced/named") + self.assertOutput(out, "ns:named") + self.assertOutput(out, "/namespaced/unnamed") + + self.assertNotInOutput(out, "/nons/named") + self.assertNotInOutput(out, "app_with_urls:named") + self.assertNotInOutput(out, "/nons/unnamed") + + class MainModule(AdminScriptTestCase): """python -m django works like django-admin."""