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.
This commit is contained in:
Jacob Walls
2025-11-29 18:45:39 -05:00
parent ce36c35e76
commit 34186e731c
8 changed files with 138 additions and 32 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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`).

View File

@@ -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()

View File

@@ -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):

View File

@@ -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)