diff options
| author | Jacob Walls <jacobtylerwalls@gmail.com> | 2025-11-29 18:45:39 -0500 |
|---|---|---|
| committer | Jacob Walls <jacobtylerwalls@gmail.com> | 2025-12-01 20:49:53 -0500 |
| commit | ec7327453d266d31a00060aeb5b9f19e5adfb0a4 (patch) | |
| tree | 991b15a8439d4d768ae6256e378b8e2036c2f51e | |
| parent | 2e3953f0a2de7bb702d393279d9dbbe2581a21a5 (diff) | |
[6.0.x] Fixed #36712 -- Evaluated type annotations lazily in template tag registration.
Ideally, this will be reverted when an upstream solution is available for
https://github.com/python/cpython/issues/141560.
Thanks Patrick Rauscher for the report and Augusto Pontes for the
first iteration and test.
Backport of 34186e731ca20a2344b1f88fd543a854d6b13a00 from main.
| -rw-r--r-- | django/contrib/admin/templatetags/base.py | 8 | ||||
| -rw-r--r-- | django/template/base.py | 4 | ||||
| -rw-r--r-- | django/template/library.py | 59 | ||||
| -rw-r--r-- | django/utils/inspect.py | 32 | ||||
| -rw-r--r-- | docs/releases/5.2.9.txt | 4 | ||||
| -rw-r--r-- | tests/admin_views/test_templatetags.py | 20 | ||||
| -rw-r--r-- | tests/template_tests/test_library.py | 26 | ||||
| -rw-r--r-- | tests/template_tests/test_parser.py | 17 |
8 files changed, 138 insertions, 32 deletions
diff --git a/django/contrib/admin/templatetags/base.py b/django/contrib/admin/templatetags/base.py index 9551c0e71d..3f8290d3b1 100644 --- a/django/contrib/admin/templatetags/base.py +++ b/django/contrib/admin/templatetags/base.py @@ -1,6 +1,7 @@ from inspect import getfullargspec from django.template.library import InclusionNode, parse_bits +from django.utils.inspect import lazy_annotations class InclusionAdminNode(InclusionNode): @@ -11,9 +12,10 @@ class InclusionAdminNode(InclusionNode): def __init__(self, parser, token, func, template_name, takes_context=True): self.template_name = template_name - params, varargs, varkw, defaults, kwonly, kwonly_defaults, _ = getfullargspec( - func - ) + with lazy_annotations(): + params, varargs, varkw, defaults, kwonly, kwonly_defaults, _ = ( + getfullargspec(func) + ) bits = token.split_contents() args, kwargs = parse_bits( parser, diff --git a/django/template/base.py b/django/template/base.py index 5e541c3960..d6595e38e7 100644 --- a/django/template/base.py +++ b/django/template/base.py @@ -60,6 +60,7 @@ from django.template.context import BaseContext from django.utils.deprecation import django_file_prefixes from django.utils.formats import localize from django.utils.html import conditional_escape +from django.utils.inspect import lazy_annotations from django.utils.regex_helper import _lazy_re_compile from django.utils.safestring import SafeData, SafeString, mark_safe from django.utils.text import get_text_list, smart_split, unescape_string_literal @@ -825,7 +826,8 @@ class FilterExpression: # Check to see if a decorator is providing the real function. func = inspect.unwrap(func) - args, _, _, defaults, _, _, _ = inspect.getfullargspec(func) + with lazy_annotations(): + args, _, _, defaults, _, _, _ = inspect.getfullargspec(func) alen = len(args) dlen = len(defaults or []) # Not enough OR Too many diff --git a/django/template/library.py b/django/template/library.py index 3fc4c5ebfc..27af7a6969 100644 --- a/django/template/library.py +++ b/django/template/library.py @@ -4,6 +4,7 @@ from importlib import import_module from inspect import getfullargspec, unwrap from django.utils.html import conditional_escape +from django.utils.inspect import lazy_annotations from .base import Node, Template, token_kwargs from .exceptions import TemplateSyntaxError @@ -110,15 +111,16 @@ class Library: """ def dec(func): - ( - params, - varargs, - varkw, - defaults, - kwonly, - kwonly_defaults, - _, - ) = getfullargspec(unwrap(func)) + with lazy_annotations(): + ( + params, + varargs, + varkw, + defaults, + kwonly, + kwonly_defaults, + _, + ) = getfullargspec(unwrap(func)) function_name = name or func.__name__ @wraps(func) @@ -165,16 +167,16 @@ class Library: def dec(func): nonlocal end_name - - ( - params, - varargs, - varkw, - defaults, - kwonly, - kwonly_defaults, - _, - ) = getfullargspec(unwrap(func)) + with lazy_annotations(): + ( + params, + varargs, + varkw, + defaults, + kwonly, + kwonly_defaults, + _, + ) = getfullargspec(unwrap(func)) function_name = name or func.__name__ if end_name is None: @@ -249,15 +251,16 @@ class Library: """ def dec(func): - ( - params, - varargs, - varkw, - defaults, - kwonly, - kwonly_defaults, - _, - ) = getfullargspec(unwrap(func)) + with lazy_annotations(): + ( + params, + varargs, + varkw, + defaults, + kwonly, + kwonly_defaults, + _, + ) = getfullargspec(unwrap(func)) function_name = name or func.__name__ @wraps(func) diff --git a/django/utils/inspect.py b/django/utils/inspect.py index ac5e82ca14..f0f43ae17e 100644 --- a/django/utils/inspect.py +++ b/django/utils/inspect.py @@ -1,11 +1,19 @@ import functools import inspect +import threading +from contextlib import contextmanager from django.utils.version import PY314 if PY314: import annotationlib + lock = threading.Lock() + safe_signature_from_callable = functools.partial( + inspect._signature_from_callable, + annotation_format=annotationlib.Format.FORWARDREF, + ) + @functools.lru_cache(maxsize=512) def _get_func_parameters(func, remove_first): @@ -98,3 +106,27 @@ def is_module_level_function(func): return False return True + + +@contextmanager +def lazy_annotations(): + """ + inspect.getfullargspec eagerly evaluates type annotations. To add + compatibility with Python 3.14+ deferred evaluation, patch the module-level + helper to provide the annotation_format that we are using elsewhere. + + This private helper could be removed when there is an upstream solution for + https://github.com/python/cpython/issues/141560. + + This context manager is not reentrant. + """ + if not PY314: + yield + return + with lock: + original_helper = inspect._signature_from_callable + inspect._signature_from_callable = safe_signature_from_callable + try: + yield + finally: + inspect._signature_from_callable = original_helper diff --git a/docs/releases/5.2.9.txt b/docs/releases/5.2.9.txt index 8a8000a9f1..9dfcc392a0 100644 --- a/docs/releases/5.2.9.txt +++ b/docs/releases/5.2.9.txt @@ -26,3 +26,7 @@ Bugfixes :class:`~django.http.HttpResponseRedirect` and :class:`~django.http.HttpResponsePermanentRedirect` for URLs longer than 2048 characters. The limit is now 16384 characters (:ticket:`36743`). + +* Fixed a crash on Python 3.14+ that prevented template tag functions from + being registered when their type annotations required deferred evaluation + (:ticket:`36712`). diff --git a/tests/admin_views/test_templatetags.py b/tests/admin_views/test_templatetags.py index 185fe156a6..e8129cce1d 100644 --- a/tests/admin_views/test_templatetags.py +++ b/tests/admin_views/test_templatetags.py @@ -1,13 +1,17 @@ import datetime +import unittest from django.contrib.admin import ModelAdmin from django.contrib.admin.templatetags.admin_list import date_hierarchy from django.contrib.admin.templatetags.admin_modify import submit_row +from django.contrib.admin.templatetags.base import InclusionAdminNode from django.contrib.auth import get_permission_codename from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User +from django.template.base import Token, TokenType from django.test import RequestFactory, TestCase from django.urls import reverse +from django.utils.version import PY314 from .admin import ArticleAdmin, site from .models import Article, Question @@ -131,6 +135,22 @@ class AdminTemplateTagsTest(AdminViewBasicTestCase): self.assertContains(response, "override-pagination") self.assertContains(response, "override-search_form") + @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only") + def test_inclusion_admin_node_deferred_annotation(self): + def action(arg: SomeType = None): # NOQA: F821 + pass + + # This used to raise TypeError via the underlying call to + # inspect.getfullargspec(), which is not ready for deferred + # evaluation of annotations. + InclusionAdminNode( + parser=object(), + token=Token(token_type=TokenType.TEXT, contents="a"), + func=action, + template_name="test.html", + takes_context=False, + ) + class DateHierarchyTests(TestCase): factory = RequestFactory() diff --git a/tests/template_tests/test_library.py b/tests/template_tests/test_library.py index 5827d33223..609b919923 100644 --- a/tests/template_tests/test_library.py +++ b/tests/template_tests/test_library.py @@ -1,8 +1,10 @@ import functools +import unittest from django.template import Library from django.template.base import Node from django.test import SimpleTestCase +from django.utils.version import PY314 class FilterRegistrationTests(SimpleTestCase): @@ -78,6 +80,14 @@ class InclusionTagRegistrationTests(SimpleTestCase): self.assertIs(func_wrapped, func) self.assertTrue(hasattr(func_wrapped, "cache_info")) + @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only") + def test_inclusion_tag_deferred_annotation(self): + @self.library.inclusion_tag("template.html") + def func(arg: SomeType): # NOQA: F821 + return "" + + self.assertIn("func", self.library.tags) + class SimpleTagRegistrationTests(SimpleTestCase): def setUp(self): @@ -104,6 +114,14 @@ class SimpleTagRegistrationTests(SimpleTestCase): self.assertIn("name", self.library.tags) + @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only") + def test_tag_deferred_annotation(self): + @self.library.simple_tag + def func(parser, token: SomeType): # NOQA: F821 + return Node() + + self.assertIn("func", self.library.tags) + def test_simple_tag_invalid(self): msg = "Invalid arguments provided to simple_tag" with self.assertRaisesMessage(ValueError, msg): @@ -145,6 +163,14 @@ class SimpleBlockTagRegistrationTests(SimpleTestCase): self.assertIn("name", self.library.tags) + @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only") + def test_simple_block_tag_deferred_annotation(self): + @self.library.simple_block_tag + def func(content: SomeType): # NOQA: F821 + return content + + self.assertIn("func", self.library.tags) + def test_simple_block_tag_invalid(self): msg = "Invalid arguments provided to simple_block_tag" with self.assertRaisesMessage(ValueError, msg): diff --git a/tests/template_tests/test_parser.py b/tests/template_tests/test_parser.py index 317fb88238..9d68955b54 100644 --- a/tests/template_tests/test_parser.py +++ b/tests/template_tests/test_parser.py @@ -3,6 +3,8 @@ Testing some internals of the template processing. These are *not* examples to be copied in user code. """ +import unittest + from django.template import Library, TemplateSyntaxError from django.template.base import ( FilterExpression, @@ -15,6 +17,7 @@ from django.template.base import ( ) from django.template.defaultfilters import register as filter_library from django.test import SimpleTestCase +from django.utils.version import PY314 class ParserTests(SimpleTestCase): @@ -240,3 +243,17 @@ class ParserTests(SimpleTestCase): FilterExpression(num, p).resolve({}) with self.assertRaises(TemplateSyntaxError): FilterExpression(f"0|default:{num}", p).resolve({}) + + @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only") + def test_filter_deferred_annotation(self): + register = Library() + + @register.filter("example") + def example_filter(value: str, arg: SomeType): # NOQA: F821 + return f"{value}_{arg}" + + result = FilterExpression.args_check( + "example", example_filter, ["extra_example"] + ) + + self.assertIs(result, True) |
