1
0
mirror of https://github.com/django/django.git synced 2025-04-05 05:56:42 +00:00

Fixed #28800 -- Added a listurls management command

This commit is contained in:
Ülgen Sarıkavak 2024-11-05 15:02:13 +03:00
parent b9aa3239ab
commit 4dfae3a3c2
10 changed files with 632 additions and 0 deletions

View File

@ -1036,6 +1036,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
Vasiliy Stavenko <stavenko@gmail.com>

View File

@ -0,0 +1,275 @@
import json
from importlib import import_module
from io import StringIO
from django.conf import settings
from django.contrib.admindocs.views import (
extract_views_from_urlpatterns,
simplify_regex,
)
from django.core.management import color
from django.core.management.base import BaseCommand, CommandError, CommandParser
from django.utils import termcolors
FORMATS = (
"aligned",
"table",
"verbose",
"json",
"pretty-json",
)
COLORLESS_FORMATS = (
"json",
"pretty-json",
)
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.HEADER = termcolors.make_style(opts=("bold",))
self.style.ROUTE = termcolors.make_style(opts=("bold",))
self.style.VIEW_NAME = termcolors.make_style(fg="yellow", opts=("bold",))
self.style.NAME = termcolors.make_style(fg="red", opts=("bold",))
def add_arguments(self, parser: CommandParser):
super().add_arguments(parser)
# Sorting
parser.add_argument(
"--unsorted",
"-u",
action="store_true",
dest="unsorted",
help="Filter URLs by the given prefix[es]",
)
# Prefix
parser.add_argument(
"--prefix",
"-p",
dest="prefixes",
help="Only list URLs with these prefixes.",
nargs="*",
)
# Format
parser.add_argument(
"--format",
"-f",
choices=FORMATS,
default="aligned",
dest="format",
help="Formatting style of the output",
)
def handle(self, *args, **options):
# Make sure the prefixes are a list
prefixes = options["prefixes"]
if prefixes:
prefixes = prefixes if isinstance(prefixes, list) else [prefixes]
url_patterns = 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"]
# Apply sorting
if not unsorted:
url_patterns.sort()
# Apply colors
if (
color.supports_color()
and (not no_color)
and (format not in COLORLESS_FORMATS)
):
url_patterns = self.apply_color(url_patterns=url_patterns)
# Apply formatting
url_patterns = self.apply_format(
url_patterns=url_patterns,
format=format,
)
return url_patterns
def apply_color(self, url_patterns):
colored_url_patterns = []
for url_pattern in url_patterns:
# Route
route = url_pattern[0]
route = self.style.ROUTE(route)
# View
view = url_pattern[1]
module_path, module_name = view.rsplit(".", 1)
module_name = self.style.VIEW_NAME(module_name)
view = f"{module_path}.{module_name}"
# Name
name = url_pattern[2]
if name:
namespace, name = name.rsplit(":", 1) if ":" in name else ("", name)
name = self.style.NAME(name)
name = f"{namespace}:{name}" if namespace else name
# Append to the list
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_table(self, url_patterns):
formatted_str = StringIO()
widths = []
margin = 2
for columns in zip(*url_patterns, strict=False):
widths.append(len(max(columns, key=len)) + margin)
# Headers
headers = (
self.style.HEADER("Route"),
self.style.HEADER("View"),
self.style.HEADER("Name"),
)
header_parts = []
for width, header in zip(widths, headers, strict=False):
header_parts.append(header.ljust(width))
formatted_str.write(" | ".join(header_parts))
formatted_str.write("\n")
# Header - content seperator
formatted_str.write("-+-".join("-" * width for width in widths))
formatted_str.write("\n")
# Rows (content)
for row in url_patterns:
row_parts = []
for width, cdata in zip(widths, row, strict=False):
row_parts.append(cdata.ljust(width))
formatted_str.write(" | ".join(row_parts))
formatted_str.write("\n")
return formatted_str.getvalue()
def format_aligned(self, url_patterns):
formatted_str = StringIO()
widths = []
for columns in zip(*url_patterns, strict=False):
margin = 2
widths.append(len(max(columns, key=len)) + margin)
for row in url_patterns:
for width, cdata in zip(widths, row, strict=False):
formatted_str.write(cdata.ljust(width))
formatted_str.write("\n")
return formatted_str.getvalue()
def format_verbose(self, url_patterns):
formatted_str = StringIO()
for route, view, name in url_patterns:
route_str = f"{self.style.HEADER('Route:')} {route}"
view_str = f"{self.style.HEADER('View:')} {view}"
name_str = f"{self.style.HEADER('Name:')} {name}" if name else ""
seperator = "-" * 20 + "\n"
parts = (
route_str,
view_str,
name_str,
seperator,
)
formatted_str.write("\n".join(part for part in parts if part))
return formatted_str.getvalue()
def format_json(self, url_patterns, pretty=False):
indent = 4 if pretty else None
# Having keys in the resulting JSON makes it more useful
url_pattern_dicts = []
for route, view, name in url_patterns:
url_pattern_dict = {
"route": route,
"view": view,
"name": name,
}
url_pattern_dicts.append(url_pattern_dict)
return json.dumps(url_pattern_dicts, indent=indent)
def format_pretty_json(self, url_patterns):
return self.format_json(url_patterns, pretty=True)
def get_url_patterns(prefixes=None):
"""
Returns a list of URL patterns in the project with given prefixes.
Each object in the returned list is a tuple[str] with 3 elements:
(route, view, name)
"""
url_patterns = []
urlconf = import_module(settings.ROOT_URLCONF)
for view_func, regex, namespace_list, name in extract_views_from_urlpatterns(
urlconf.urlpatterns
):
# Route
route = simplify_regex(regex)
# View
view = "{}.{}".format(
view_func.__module__,
getattr(view_func, "__name__", view_func.__class__.__name__),
)
# Name
namespace = ""
if namespace_list:
for part in namespace_list:
namespace += part + ":"
name = namespace + name if name else None
name = name or ""
# Append to the list
url_patterns.append((route, view, name))
# Filter out when prefixes are given but the pattern's route doesn't match
if prefixes:
url_patterns = [
url_pattern
for url_pattern in url_patterns
if any(url_pattern[0].startswith(prefix) for prefix in prefixes)
]
return url_patterns

View File

@ -498,6 +498,18 @@ Only support for PostgreSQL is implemented.
If this option is provided, models are also created for database views.
``listurls``
------------
.. django-admin:: listurls [url_prefix ...]
Displays a list of the URLs and their associated short names and views in the
project. Optionally restrict to one or more prefixes.
.. django-admin-option:: --table
Output the list of URLs as a table, one URL per line.
``loaddata``
------------

View File

@ -255,6 +255,9 @@ Management Commands
``Command.autodetector`` attribute for subclasses to override in order to use
a custom autodetector class.
* Introduce a :djadmin:`listurls` command that lists the URLs in the application,
including the view function and name, if present.
Migrations
~~~~~~~~~~

View File

@ -0,0 +1,12 @@
from django.urls import include, path
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"),
),
]

View File

@ -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",
),
]

View File

@ -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",
),
]

View File

@ -0,0 +1,14 @@
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

View File

@ -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
@ -3148,6 +3149,286 @@ class Dumpdata(AdminScriptTestCase):
self.assertNoOutput(out)
class Listurls(AdminScriptTestCase):
"""
Tests for the listurls command.
"""
@override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls")
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",
)
@override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls")
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",
)
@override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls")
def test_table(self):
self.write_settings(
"settings.py",
apps=["admin_scripts.app_with_urls"],
)
args = ["listurls", "-f", "table"]
out, err = self.run_manage(args)
self.assertNoOutput(err)
# Check table headers
self.assertOutput(out, "Route")
self.assertOutput(out, "View")
self.assertOutput(out, "Name")
self.assertOutput(out, "---+---")
# 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",
)
@override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls")
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",
)
@override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls")
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)
try:
json.loads(out)
except json.JSONDecodeError:
self.fail("Output is not valid JSON")
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",
)
@override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls")
def test_pretty_json(self):
self.write_settings(
"settings.py",
apps=["admin_scripts.app_with_urls"],
)
args = ["listurls", "-f", "pretty-json"]
out, err = self.run_manage(args)
self.assertNoOutput(err)
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",
)
@override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls")
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_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)
@override_settings(ROOT_URLCONF="admin_scripts.app_with_urls.root_urls")
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."""