From 7f3cfaa12b28d15c0ca78bb692bfd6e59d17bff1 Mon Sep 17 00:00:00 2001 From: Alokik Vijay Date: Tue, 17 May 2022 09:46:26 +0200 Subject: [PATCH] Fixed #32565 -- Moved internal URLResolver view-strings mapping to admindocs. Moved the functionality of URLResolver._is_callback(), URLResolver._callback_strs, URLPattern.lookup_str() to django.contrib.admindocs. --- AUTHORS | 1 + django/contrib/admindocs/apps.py | 8 ++++++ django/contrib/admindocs/utils.py | 44 ++++++++++++++++++++++++++++++ django/contrib/admindocs/views.py | 5 ++-- django/urls/resolvers.py | 25 ----------------- docs/releases/4.1.txt | 4 +++ tests/admin_docs/test_utils.py | 29 +++++++++++++++++++- tests/urlpatterns_reverse/tests.py | 21 -------------- 8 files changed, 87 insertions(+), 50 deletions(-) diff --git a/AUTHORS b/AUTHORS index 3a9a521c19..1f39fdb6c4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -58,6 +58,7 @@ answer newbie questions, and generally made Django that much better: Ali Vakilzade Aljaž Košir Aljosa Mohorovic + Alokik Vijay Amit Chakradeo Amit Ramon Amit Upadhyay diff --git a/django/contrib/admindocs/apps.py b/django/contrib/admindocs/apps.py index e79dc892cb..2fda5fb4fb 100644 --- a/django/contrib/admindocs/apps.py +++ b/django/contrib/admindocs/apps.py @@ -1,7 +1,15 @@ from django.apps import AppConfig +from django.urls import get_resolver, get_urlconf from django.utils.translation import gettext_lazy as _ +from .utils import _active, register_callback + class AdminDocsConfig(AppConfig): name = "django.contrib.admindocs" verbose_name = _("Administrative Documentation") + + def ready(self): + urlconf = get_urlconf() + urlresolver = get_resolver(urlconf) + register_callback(urlresolver, _active.local_value) diff --git a/django/contrib/admindocs/utils.py b/django/contrib/admindocs/utils.py index 6edff502ec..a18dbe12cd 100644 --- a/django/contrib/admindocs/utils.py +++ b/django/contrib/admindocs/utils.py @@ -1,11 +1,15 @@ "Misc. utility functions/classes for admin documentation generator." +import functools import re from email.errors import HeaderParseError from email.parser import HeaderParser from inspect import cleandoc +from asgiref.local import Local + from django.urls import reverse +from django.urls.resolvers import URLPattern from django.utils.regex_helper import _lazy_re_compile from django.utils.safestring import mark_safe @@ -239,3 +243,43 @@ def remove_non_capturing_groups(pattern): final_pattern += pattern[prev_end:start] prev_end = end return final_pattern + pattern[prev_end:] + + +# Callback strings are cached in a dictionary for every urlconf. +# The active calback_strs are stored by thread id to make them thread local. +_callback_strs = set() +_active = Local() +_active.local_value = _callback_strs + + +def _is_callback(name, urlresolver=None): + if urlresolver and not urlresolver._populated: + register_callback(urlresolver, _active.local_value) + return name in _active.local_value + + +@functools.lru_cache(maxsize=None) +def lookup_str(urlpattern): + """ + A string that identifies the view (e.g. 'path.to.view_function' or + 'path.to.ClassBasedView'). + """ + callback = urlpattern.callback + if isinstance(callback, functools.partial): + callback = callback.func + if hasattr(callback, "view_class"): + callback = callback.view_class + elif not hasattr(callback, "__name__"): + return callback.__module__ + "." + callback.__class__.__name__ + return callback.__module__ + "." + callback.__qualname__ + + +def register_callback(urlresolver, thread): + for url_pattern in reversed(urlresolver.url_patterns): + if isinstance(url_pattern, URLPattern): + thread.add(lookup_str(url_pattern)) + else: # url_pattern is a URLResolver. + _active.url_pattern_value = _callback_strs + register_callback(url_pattern, _active.url_pattern_value) + thread.update(_active.url_pattern_value) + urlresolver._populated = True diff --git a/django/contrib/admindocs/views.py b/django/contrib/admindocs/views.py index 1d08541057..f771e8f391 100644 --- a/django/contrib/admindocs/views.py +++ b/django/contrib/admindocs/views.py @@ -30,7 +30,7 @@ from django.utils.inspect import ( from django.utils.translation import gettext as _ from django.views.generic import TemplateView -from .utils import get_view_name +from .utils import _is_callback, get_view_name # Exclude methods starting with these strings from documentation MODEL_METHODS_EXCLUDE = ("_", "add_", "delete", "save", "set_") @@ -166,8 +166,7 @@ class ViewDetailView(BaseAdminDocsView): @staticmethod def _get_view_func(view): - urlconf = get_urlconf() - if get_resolver(urlconf)._is_callback(view): + if _is_callback(view): mod, func = get_mod_func(view) try: # Separate the module and function, e.g. diff --git a/django/urls/resolvers.py b/django/urls/resolvers.py index 9f42e2738c..7c0d4fd85a 100644 --- a/django/urls/resolvers.py +++ b/django/urls/resolvers.py @@ -437,21 +437,6 @@ class URLPattern: extra_kwargs=self.default_args, ) - @cached_property - def lookup_str(self): - """ - A string that identifies the view (e.g. 'path.to.view_function' or - 'path.to.ClassBasedView'). - """ - callback = self.callback - if isinstance(callback, functools.partial): - callback = callback.func - if hasattr(callback, "view_class"): - callback = callback.view_class - elif not hasattr(callback, "__name__"): - return callback.__module__ + "." + callback.__class__.__name__ - return callback.__module__ + "." + callback.__qualname__ - class URLResolver: def __init__( @@ -469,9 +454,6 @@ class URLResolver: self._reverse_dict = {} self._namespace_dict = {} self._app_dict = {} - # set of dotted paths to all functions and classes that are used in - # urlpatterns - self._callback_strs = set() self._populated = False self._local = Local() @@ -545,7 +527,6 @@ class URLResolver: if p_pattern.startswith("^"): p_pattern = p_pattern[1:] if isinstance(url_pattern, URLPattern): - self._callback_strs.add(url_pattern.lookup_str) bits = normalize(url_pattern.pattern.regex.pattern) lookups.appendlist( url_pattern.callback, @@ -604,7 +585,6 @@ class URLResolver: namespaces[namespace] = (p_pattern + prefix, sub_pattern) for app_name, namespace_list in url_pattern.app_dict.items(): apps.setdefault(app_name, []).extend(namespace_list) - self._callback_strs.update(url_pattern._callback_strs) self._namespace_dict[language_code] = namespaces self._app_dict[language_code] = apps self._reverse_dict[language_code] = lookups @@ -649,11 +629,6 @@ class URLResolver: route2 = route2[1:] return route1 + route2 - def _is_callback(self, name): - if not self._populated: - self._populate() - return name in self._callback_strs - def resolve(self, path): path = str(path) # path may be a reverse_lazy object tried = [] diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt index bd8af7d51c..600da9dea9 100644 --- a/docs/releases/4.1.txt +++ b/docs/releases/4.1.txt @@ -594,6 +594,10 @@ Miscellaneous :meth:`~django.db.models.BaseConstraint.validate` method to allow those constraints to be used for validation. +* The undocumented ``URLResolver._is_callback()``, + ``URLResolver._callback_strs``, and ``URLPattern.lookup_str()`` have been + moved to ``django.contrib.admindocs.utils``. + .. _deprecated-features-4.1: Features deprecated in 4.1 diff --git a/tests/admin_docs/test_utils.py b/tests/admin_docs/test_utils.py index 18c6769fad..e26a4ca1f3 100644 --- a/tests/admin_docs/test_utils.py +++ b/tests/admin_docs/test_utils.py @@ -1,13 +1,15 @@ import unittest from django.contrib.admindocs.utils import ( + _is_callback, docutils_is_available, parse_docstring, parse_rst, ) from django.test.utils import captured_stderr +from django.urls import get_resolver -from .tests import AdminDocsSimpleTestCase +from .tests import AdminDocsSimpleTestCase, SimpleTestCase @unittest.skipUnless(docutils_is_available, "no docutils installed.") @@ -119,3 +121,28 @@ class TestUtils(AdminDocsSimpleTestCase): markup = "

reST, interpreted text, default role.

\n" parts = docutils.core.publish_parts(source=source, writer_name="html4css1") self.assertEqual(parts["fragment"], markup) + + +class TestResolver(SimpleTestCase): + def test_namespaced_view_detail(self): + resolver = get_resolver("urlpatterns_reverse.nested_urls") + self.assertTrue(_is_callback("urlpatterns_reverse.nested_urls.view1", resolver)) + self.assertTrue(_is_callback("urlpatterns_reverse.nested_urls.view2", resolver)) + self.assertTrue(_is_callback("urlpatterns_reverse.nested_urls.View3", resolver)) + self.assertFalse(_is_callback("urlpatterns_reverse.nested_urls.blub", resolver)) + + def test_view_detail_as_method(self): + # Views which have a class name as part of their path. + resolver = get_resolver("urlpatterns_reverse.method_view_urls") + self.assertTrue( + _is_callback( + "urlpatterns_reverse.method_view_urls.ViewContainer.method_view", + resolver, + ) + ) + self.assertTrue( + _is_callback( + "urlpatterns_reverse.method_view_urls.ViewContainer.classmethod_view", + resolver, + ) + ) diff --git a/tests/urlpatterns_reverse/tests.py b/tests/urlpatterns_reverse/tests.py index 89dfd0deba..73159b6bb8 100644 --- a/tests/urlpatterns_reverse/tests.py +++ b/tests/urlpatterns_reverse/tests.py @@ -640,27 +640,6 @@ class ResolverTests(SimpleTestCase): % (e["name"], t.name), ) - def test_namespaced_view_detail(self): - resolver = get_resolver("urlpatterns_reverse.nested_urls") - self.assertTrue(resolver._is_callback("urlpatterns_reverse.nested_urls.view1")) - self.assertTrue(resolver._is_callback("urlpatterns_reverse.nested_urls.view2")) - self.assertTrue(resolver._is_callback("urlpatterns_reverse.nested_urls.View3")) - self.assertFalse(resolver._is_callback("urlpatterns_reverse.nested_urls.blub")) - - def test_view_detail_as_method(self): - # Views which have a class name as part of their path. - resolver = get_resolver("urlpatterns_reverse.method_view_urls") - self.assertTrue( - resolver._is_callback( - "urlpatterns_reverse.method_view_urls.ViewContainer.method_view" - ) - ) - self.assertTrue( - resolver._is_callback( - "urlpatterns_reverse.method_view_urls.ViewContainer.classmethod_view" - ) - ) - def test_populate_concurrency(self): """ URLResolver._populate() can be called concurrently, but not more