summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Walls <jacobtylerwalls@gmail.com>2025-11-29 18:45:39 -0500
committerJacob Walls <jacobtylerwalls@gmail.com>2025-12-01 20:51:26 -0500
commitda1dfe64c821ba03ca7b0c936184cca1ad641316 (patch)
treed0a0fdfed6174f8d2fa3cc47e4d439edd2599416
parente2ddec431395330b423ef193548f374b5c2f395e (diff)
[5.2.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.py8
-rw-r--r--django/template/base.py4
-rw-r--r--django/template/library.py59
-rw-r--r--django/utils/inspect.py32
-rw-r--r--docs/releases/5.2.9.txt4
-rw-r--r--tests/admin_views/test_templatetags.py20
-rw-r--r--tests/template_tests/test_library.py26
-rw-r--r--tests/template_tests/test_parser.py17
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 eaca428b10..5a0941564d 100644
--- a/django/template/base.py
+++ b/django/template/base.py
@@ -58,6 +58,7 @@ from enum import Enum
from django.template.context import BaseContext
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
@@ -760,7 +761,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 d181caa832..4ffefa616a 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
@@ -109,15 +110,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)
@@ -164,16 +166,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:
@@ -248,15 +250,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 e1d790cc3c..6f6bd7b7b9 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):
@@ -88,3 +96,27 @@ def method_has_no_args(meth):
def func_supports_parameter(func, name):
return any(param.name == name for param in _get_callable_parameters(func))
+
+
+@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 eb3bb49113..199148970f 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,
@@ -14,6 +16,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):
@@ -148,3 +151,17 @@ class ParserTests(SimpleTestCase):
'1|two_one_opt_arg:"1"',
):
FilterExpression(expr, parser)
+
+ @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)