Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions django/contrib/admin/templatetags/base.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion django/template/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 31 additions & 28 deletions django/template/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions django/utils/inspect.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions docs/releases/5.2.9.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
20 changes: 20 additions & 0 deletions tests/admin_views/test_templatetags.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down
26 changes: 26 additions & 0 deletions tests/template_tests/test_library.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
17 changes: 17 additions & 0 deletions tests/template_tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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)