diff options
| author | Chris Rose <offline@offby1.net> | 2026-05-18 10:49:34 -0700 |
|---|---|---|
| committer | Jacob Walls <jacobtylerwalls@gmail.com> | 2026-06-01 15:24:49 -0400 |
| commit | 9383fae0d55a553be3bda620db87fa9ee8a81478 (patch) | |
| tree | 7c46277248d4ed0fc38eb2193d95c844fc146726 | |
| parent | 22d25eff1510eb157be3ef02984869b1309ec15d (diff) | |
Fixed #28800 -- Added a listurls management command.
Thanks JaeHyuck Sa, Jacob Walls, and Tim McCurrach for reviews.
Co-authored-by: Ülgen Sarıkavak <ulgensrkvk@gmail.com>
| -rw-r--r-- | AUTHORS | 2 | ||||
| -rw-r--r-- | django/core/management/commands/listurls.py | 175 | ||||
| -rw-r--r-- | django/utils/termcolors.py | 9 | ||||
| -rw-r--r-- | docs/ref/django-admin.txt | 25 | ||||
| -rw-r--r-- | docs/releases/6.2.txt | 3 | ||||
| -rw-r--r-- | tests/admin_scripts/app_with_urls/__init__.py | 0 | ||||
| -rw-r--r-- | tests/admin_scripts/app_with_urls/root_urls.py | 25 | ||||
| -rw-r--r-- | tests/admin_scripts/app_with_urls/urls_cbv.py | 17 | ||||
| -rw-r--r-- | tests/admin_scripts/app_with_urls/urls_namespaced.py | 17 | ||||
| -rw-r--r-- | tests/admin_scripts/app_with_urls/urls_nons.py | 17 | ||||
| -rw-r--r-- | tests/admin_scripts/app_with_urls/views.py | 21 | ||||
| -rw-r--r-- | tests/admin_scripts/tests.py | 289 |
12 files changed, 599 insertions, 1 deletions
@@ -229,6 +229,7 @@ answer newbie questions, and generally made Django that much better: Chris Jerdonek Chris Jones <chris@brack3t.com> Chris Lamb <chris@chris-lamb.co.uk> + Chris Rose <https://github.com/offbyone> Chris Streeter <chris@chrisstreeter.com> Christian Barcenas <christian@cbarcenas.com> Christian Metts @@ -1078,6 +1079,7 @@ answer newbie questions, and generally made Django that much better: Tyson Clugg <tyson@clugg.net> Tyson Tate <tyson@fallingbullets.com> Unai Zalakain <unai@gisa-elkartea.org> + Ülgen Sarıkavak <https://github.com/ulgens> Valentina Mukhamedzhanova <umirra@gmail.com> valtron Varun Kasyap Pentamaraju <varunkasyap@hotmail.com> diff --git a/django/core/management/commands/listurls.py b/django/core/management/commands/listurls.py new file mode 100644 index 0000000000..4cf2d323cf --- /dev/null +++ b/django/core/management/commands/listurls.py @@ -0,0 +1,175 @@ +# Portions of this code are derived from django-extensions (MIT): +# https://github.com/django-extensions/django-extensions + +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 = ( + "tabular", + "stacked", + "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="tabular", + 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.COMMAND_DATA(url_pattern.route) + + module_path, module_name = url_pattern.view.rsplit(".", 1) + module_name = self.style.COMMAND_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.COMMAND_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_tabular(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_stacked(self, url_patterns): + separator = "-" * 20 + apply_style = ( + self.style.COMMAND_HEADER if self.is_color_enabled else lambda text: text + ) + + lines = [] + for route, view, name in url_patterns: + lines.append(apply_style("Route: ") + route) + lines.append(apply_style("View: ") + view) + if name: + lines.append(apply_style("Name: ") + name) + lines.append(separator) + + 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 aeef02f1b0..26df7b76d0 100644 --- a/django/utils/termcolors.py +++ b/django/utils/termcolors.py @@ -97,6 +97,9 @@ PALETTES = { "HTTP_SERVER_ERROR": {}, "MIGRATE_HEADING": {}, "MIGRATE_LABEL": {}, + "COMMAND_HEADER": {}, + "COMMAND_DATA": {}, + "COMMAND_HIGHLIGHT": {}, }, DARK_PALETTE: { "ERROR": {"fg": "red", "opts": ("bold",)}, @@ -116,6 +119,9 @@ PALETTES = { "HTTP_SERVER_ERROR": {"fg": "magenta", "opts": ("bold",)}, "MIGRATE_HEADING": {"fg": "cyan", "opts": ("bold",)}, "MIGRATE_LABEL": {"opts": ("bold",)}, + "COMMAND_HEADER": {"fg": "cyan", "opts": ("bold",)}, + "COMMAND_DATA": {"opts": ("bold",)}, + "COMMAND_HIGHLIGHT": {"fg": "yellow", "opts": ("bold",)}, }, LIGHT_PALETTE: { "ERROR": {"fg": "red", "opts": ("bold",)}, @@ -135,6 +141,9 @@ PALETTES = { "HTTP_SERVER_ERROR": {"fg": "magenta", "opts": ("bold",)}, "MIGRATE_HEADING": {"fg": "cyan", "opts": ("bold",)}, "MIGRATE_LABEL": {"opts": ("bold",)}, + "COMMAND_HEADER": {"fg": "cyan", "opts": ("bold",)}, + "COMMAND_DATA": {"opts": ("bold",)}, + "COMMAND_HIGHLIGHT": {"fg": "yellow", "opts": ("bold",)}, }, } DEFAULT_PALETTE = DARK_PALETTE diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index adbd2465a7..fc8de971ec 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -499,6 +499,31 @@ 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 from the project's root :doc:`URLconf </topics/http/urls>` +with optional filtering by prefixes, inspired by ``show_urls`` from +``django-extensions``. + +.. django-admin-option:: --unsorted, -u + +Lists URLs in the original ordering from ``urlpatterns``. The default ordering +is alphabetical by route. + +.. django-admin-option:: --prefix, -p [prefix ...] + +Filters URLs by given prefixes. + +.. django-admin-option:: --format, -f {tabular,stacked,json} + +Specifies the output format. Available values are ``tabular``, ``stacked``, and +``json``. Default is ``tabular``. + ``loaddata`` ------------ diff --git a/docs/releases/6.2.txt b/docs/releases/6.2.txt index 3bafa4f583..f88beb6fa0 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 from the project's root + URLconf, 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 0000000000..e69de29bb2 --- /dev/null +++ b/tests/admin_scripts/app_with_urls/__init__.py 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 0000000000..df14128138 --- /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 0000000000..ec4740f5b9 --- /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 0000000000..a993271c79 --- /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 0000000000..e957d09fa2 --- /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 0000000000..64035868d4 --- /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 914f54720c..819ba931d6 100644 --- a/tests/admin_scripts/tests.py +++ b/tests/admin_scripts/tests.py @@ -4,6 +4,7 @@ advertised - especially with regards to the handling of the DJANGO_SETTINGS_MODULE and default settings.py files. """ +import json import os import re import shutil @@ -30,6 +31,7 @@ from django.core.management import ( 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,293 @@ class Dumpdata(AdminScriptTestCase): 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_tabular(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "tabular"] + 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_stacked(self): + self.write_settings( + "settings.py", + apps=["admin_scripts.app_with_urls"], + ) + + args = ["listurls", "-f", "stacked"] + 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_tabular_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="tabular") + + self.assertIn(command.style.COMMAND_DATA("/namespaced/named"), out.getvalue()) + self.assertIn( + command.style.COMMAND_HIGHLIGHT("view_func_namespaced_named"), + out.getvalue(), + ) + self.assertIn(command.style.COMMAND_HIGHLIGHT("named"), out.getvalue()) + + def test_tabular_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="tabular", 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_stacked_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="stacked") + + self.assertIn(command.style.COMMAND_DATA("/namespaced/named"), out.getvalue()) + self.assertIn( + command.style.COMMAND_HIGHLIGHT("view_func_namespaced_named"), + out.getvalue(), + ) + self.assertIn(command.style.COMMAND_HIGHLIGHT("named"), out.getvalue()) + for header in ["Route", "View", "Name"]: + with self.subTest(header=header): + self.assertIn( + command.style.COMMAND_HEADER(f"{header}: "), out.getvalue() + ) + + def test_stacked_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="stacked", 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.""" |
