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:51:26 -0500 |
| commit | da1dfe64c821ba03ca7b0c936184cca1ad641316 (patch) | |
| tree | d0a0fdfed6174f8d2fa3cc47e4d439edd2599416 | |
| parent | e2ddec431395330b423ef193548f374b5c2f395e (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.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 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) |
