From 34186e731ca20a2344b1f88fd543a854d6b13a00 Mon Sep 17 00:00:00 2001 From: Jacob Walls Date: Sat, 29 Nov 2025 18:45:39 -0500 Subject: [PATCH] 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. --- django/contrib/admin/templatetags/base.py | 8 +-- django/template/base.py | 4 +- django/template/library.py | 59 ++++++++++++----------- django/utils/inspect.py | 32 ++++++++++++ docs/releases/5.2.9.txt | 4 ++ tests/admin_views/test_templatetags.py | 20 ++++++++ tests/template_tests/test_library.py | 26 ++++++++++ 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 9551c0e71df8..3f8290d3b106 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 5e541c3960ee..d6595e38e701 100644 --- a/django/template/base.py +++ b/django/template/base.py @@ -60,6 +60,7 @@ 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 @@ def args_check(name, func, provided): # 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 3fc4c5ebfc2b..27af7a696994 100644 --- a/django/template/library.py +++ b/django/template/library.py @@ -4,6 +4,7 @@ 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 @@ def hello(*args, **kwargs): """ 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 @@ def hello(content): 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 @@ def show_results(poll): """ 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 ac5e82ca1414..f0f43ae17e35 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 8a8000a9f112..9dfcc392a036 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 185fe156a6c5..e8129cce1dfa 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 @@ def test_override_change_list_template_tags(self): 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 5827d3322333..609b9199230f 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 @@ def func(): 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 @@ def func(): 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 @@ def func(content): 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 317fb882382e..9d68955b54ad 100644 --- a/tests/template_tests/test_parser.py +++ b/tests/template_tests/test_parser.py @@ -3,6 +3,8 @@ 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.defaultfilters import register as filter_library from django.test import SimpleTestCase +from django.utils.version import PY314 class ParserTests(SimpleTestCase): @@ -240,3 +243,17 @@ def test_filter_numeric_argument_parsing(self): 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)