From 5e06b970956af4854e970e74990cb971ba31c96b Mon Sep 17 00:00:00 2001 From: farhan Date: Tue, 3 Jun 2025 10:32:34 +0500 Subject: [PATCH] Fixed #36410 -- Added support for Template Partials to the Django Template Language. Introduced `{% partialdef %}` and `{% partial %}` template tags to define and render reusable named fragments within a template file. Partials can also be accessed using the `template_name#partial_name` syntax via `get_template()`, `render()`, `{% include %}`, and other template-loading tools. Adjusted `get_template()` behavior to support partial resolution, with appropriate error handling for invalid names and edge cases. Introduced `PartialTemplate` to encapsulate partial rendering behavior. Includes tests and internal refactors to support partial context binding, exception reporting, and tag validation. Co-authored-by: Carlton Gibson Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> Co-authored-by: Nick Pope --- django/template/__init__.py | 1 + django/template/base.py | 56 ++ django/template/defaulttags.py | 96 +++ django/template/engine.py | 22 +- django/test/utils.py | 5 +- django/utils/datastructures.py | 18 + docs/ref/templates/builtins.txt | 74 ++ docs/ref/templates/language.txt | 144 ++++ docs/releases/6.0.txt | 15 + docs/topics/templates.txt | 27 + .../syntax_tests/test_partials.py | 652 ++++++++++++++++++ .../templates/partial_base.html | 2 + .../templates/partial_child.html | 9 + .../templates/partial_examples.html | 15 + tests/template_tests/test_partials.py | 413 +++++++++++ tests/utils_tests/test_datastructures.py | 40 ++ 16 files changed, 1587 insertions(+), 2 deletions(-) create mode 100644 tests/template_tests/syntax_tests/test_partials.py create mode 100644 tests/template_tests/templates/partial_base.html create mode 100644 tests/template_tests/templates/partial_child.html create mode 100644 tests/template_tests/templates/partial_examples.html create mode 100644 tests/template_tests/test_partials.py diff --git a/django/template/__init__.py b/django/template/__init__.py index adb431c00d..92568da793 100644 --- a/django/template/__init__.py +++ b/django/template/__init__.py @@ -61,6 +61,7 @@ from .base import ( # NOQA isort:skip Node, NodeList, Origin, + PartialTemplate, Template, Variable, ) diff --git a/django/template/base.py b/django/template/base.py index 3e8a59fbe7..74b3987410 100644 --- a/django/template/base.py +++ b/django/template/base.py @@ -88,6 +88,11 @@ UNKNOWN_SOURCE = "" # than instantiating SimpleLazyObject with _lazy_re_compile(). tag_re = re.compile(r"({%.*?%}|{{.*?}}|{#.*?#})") +combined_partial_re = re.compile( + r"{%\s*partialdef\s+(?P[\w-]+)(?:\s+inline)?\s*%}" + r"|{%\s*endpartialdef(?:\s+[\w-]+)?\s*%}" +) + logger = logging.getLogger("django.template") @@ -288,6 +293,57 @@ class Template: } +class PartialTemplate: + """ + A lightweight Template lookalike used for template partials. + + Wraps nodelist as a partial, in order to be able to bind context. + """ + + def __init__(self, nodelist, origin, name): + self.nodelist = nodelist + self.origin = origin + self.name = name + + def get_exception_info(self, exception, token): + template = self.origin.loader.get_template(self.origin.template_name) + return template.get_exception_info(exception, token) + + def find_partial_source(self, full_source, partial_name): + start_match = None + nesting = 0 + + for match in combined_partial_re.finditer(full_source): + if name := match["name"]: # Opening tag. + if start_match is None and name == partial_name: + start_match = match + if start_match is not None: + nesting += 1 + elif start_match is not None: + nesting -= 1 + if nesting == 0: + return full_source[start_match.start() : match.end()] + + return "" + + @property + def source(self): + template = self.origin.loader.get_template(self.origin.template_name) + return self.find_partial_source(template.source, self.name) + + def _render(self, context): + return self.nodelist.render(context) + + def render(self, context): + with context.render_context.push_state(self): + if context.template is None: + with context.bind_template(self): + context.template_name = self.name + return self._render(context) + else: + return self._render(context) + + def linebreak_iter(template_source): yield 0 p = template_source.find("\n") diff --git a/django/template/defaulttags.py b/django/template/defaulttags.py index a20598152c..4cbaf852e1 100644 --- a/django/template/defaulttags.py +++ b/django/template/defaulttags.py @@ -12,6 +12,7 @@ from itertools import groupby from django.conf import settings from django.http import QueryDict from django.utils import timezone +from django.utils.datastructures import DeferredSubDict from django.utils.html import conditional_escape, escape, format_html from django.utils.lorem_ipsum import paragraphs, words from django.utils.safestring import mark_safe @@ -29,6 +30,7 @@ from .base import ( VARIABLE_TAG_START, Node, NodeList, + PartialTemplate, TemplateSyntaxError, VariableDoesNotExist, kwarg_re, @@ -408,6 +410,31 @@ class NowNode(Node): return formatted +class PartialDefNode(Node): + def __init__(self, partial_name, inline, nodelist): + self.partial_name = partial_name + self.inline = inline + self.nodelist = nodelist + + def render(self, context): + return self.nodelist.render(context) if self.inline else "" + + +class PartialNode(Node): + def __init__(self, partial_name, partial_mapping): + # Defer lookup in `partial_mapping` and nodelist to runtime. + self.partial_name = partial_name + self.partial_mapping = partial_mapping + + def render(self, context): + try: + return self.partial_mapping[self.partial_name].render(context) + except KeyError: + raise TemplateSyntaxError( + f"Partial '{self.partial_name}' is not defined in the current template." + ) + + class ResetCycleNode(Node): def __init__(self, node): self.node = node @@ -1174,6 +1201,75 @@ def now(parser, token): return NowNode(format_string, asvar) +@register.tag(name="partialdef") +def partialdef_func(parser, token): + """ + Declare a partial that can be used in the template. + + Usage:: + + {% partialdef partial_name %} + Content goes here. + {% endpartialdef %} + + Store the nodelist in the context under the key "partials". It can be + retrieved using the ``{% partial %}`` tag. + + The optional ``inline`` argument renders the partial's contents + immediately, at the point where it is defined. + """ + match token.split_contents(): + case "partialdef", partial_name, "inline": + inline = True + case "partialdef", partial_name, _: + raise TemplateSyntaxError( + "The 'inline' argument does not have any parameters; either use " + "'inline' or remove it completely." + ) + case "partialdef", partial_name: + inline = False + case ["partialdef"]: + raise TemplateSyntaxError("'partialdef' tag requires a name") + case _: + raise TemplateSyntaxError("'partialdef' tag takes at most 2 arguments") + + # Parse the content until the end tag. + valid_endpartials = ("endpartialdef", f"endpartialdef {partial_name}") + nodelist = parser.parse(valid_endpartials) + endpartial = parser.next_token() + if endpartial.contents not in valid_endpartials: + parser.invalid_block_tag(endpartial, "endpartialdef", valid_endpartials) + + # Store the partial nodelist in the parser.extra_data attribute. + partials = parser.extra_data.setdefault("partials", {}) + if partial_name in partials: + raise TemplateSyntaxError( + f"Partial '{partial_name}' is already defined in the " + f"'{parser.origin.name}' template." + ) + partials[partial_name] = PartialTemplate(nodelist, parser.origin, partial_name) + + return PartialDefNode(partial_name, inline, nodelist) + + +@register.tag(name="partial") +def partial_func(parser, token): + """ + Render a partial previously declared with the ``{% partialdef %}`` tag. + + Usage:: + + {% partial partial_name %} + """ + match token.split_contents(): + case "partial", partial_name: + extra_data = parser.extra_data + partial_mapping = DeferredSubDict(extra_data, "partials") + return PartialNode(partial_name, partial_mapping=partial_mapping) + case _: + raise TemplateSyntaxError("'partial' tag requires a single argument") + + @register.simple_tag(name="querystring", takes_context=True) def querystring(context, *args, **kwargs): """ diff --git a/django/template/engine.py b/django/template/engine.py index 9882d3a16d..df5c8316b9 100644 --- a/django/template/engine.py +++ b/django/template/engine.py @@ -174,11 +174,31 @@ class Engine: Return a compiled Template object for the given template name, handling template inheritance recursively. """ + original_name = template_name + try: + template_name, _, partial_name = template_name.partition("#") + except AttributeError: + raise TemplateDoesNotExist(original_name) + + if not template_name: + raise TemplateDoesNotExist(original_name) + template, origin = self.find_template(template_name) if not hasattr(template, "render"): # template needs to be compiled template = Template(template, origin, template_name, engine=self) - return template + + if not partial_name: + return template + + extra_data = getattr(template, "extra_data", {}) + try: + partial = extra_data["partials"][partial_name] + except (KeyError, TypeError): + raise TemplateDoesNotExist(partial_name, tried=[template_name]) + partial.engine = self + + return partial def render_to_string(self, template_name, context=None): """ diff --git a/django/test/utils.py b/django/test/utils.py index ea39794e1a..3661010463 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -24,7 +24,7 @@ from django.core.exceptions import ImproperlyConfigured from django.core.signals import request_started, setting_changed from django.db import DEFAULT_DB_ALIAS, connections, reset_queries from django.db.models.options import Options -from django.template import Template +from django.template import PartialTemplate, Template from django.test.signals import template_rendered from django.urls import get_script_prefix, set_script_prefix from django.utils.translation import deactivate @@ -147,7 +147,9 @@ def setup_test_environment(debug=None): settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" saved_data.template_render = Template._render + saved_data.partial_template_render = PartialTemplate._render Template._render = instrumented_test_render + PartialTemplate._render = instrumented_test_render mail.outbox = [] @@ -165,6 +167,7 @@ def teardown_test_environment(): settings.DEBUG = saved_data.debug settings.EMAIL_BACKEND = saved_data.email_backend Template._render = saved_data.template_render + PartialTemplate._render = saved_data.partial_template_render del _TestState.saved_data del mail.outbox diff --git a/django/utils/datastructures.py b/django/utils/datastructures.py index 7c8669a350..7118c814b5 100644 --- a/django/utils/datastructures.py +++ b/django/utils/datastructures.py @@ -345,3 +345,21 @@ class CaseInsensitiveMapping(Mapping): "Element key %r invalid, only strings are allowed" % elem[0] ) yield elem + + +class DeferredSubDict: + """ + Wrap a dict, allowing deferred access to a sub-dict under a given key. + + The value at ``deferred_key`` must itself be a dict. Accessing + ``DeferredSubDict(parent_dict, deferred_key)[key]`` retrieves + ``parent_dict[deferred_key][key]`` at access time, so updates to + the parent dict are reflected. + """ + + def __init__(self, parent_dict, deferred_key): + self.parent_dict = parent_dict + self.deferred_key = deferred_key + + def __getitem__(self, key): + return self.parent_dict[self.deferred_key][key] diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index 1eb391d8c0..464c4d83ac 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -957,6 +957,80 @@ output (as a string) inside a variable. This is useful if you want to use {% now "Y" as current_year %} {% blocktranslate %}Copyright {{ current_year }}{% endblocktranslate %} +.. templatetag:: partial + +``partial`` +----------- + +.. versionadded:: 6.0 + +Renders a template fragment that was defined with :ttag:`partialdef`, inserting +the matching partial at this location. + +Usage: + +.. code-block:: html+django + + {% partial partial_name %} + +The ``partial_name`` argument is the name of the template fragment to render. + +In the following example, a partial named ``button`` is defined and then +rendered three times: + +.. code-block:: html+django + + {% partialdef button %} + + {% endpartialdef %} + + {% partial button %} + {% partial button %} + {% partial button %} + +.. templatetag:: partialdef + +``partialdef`` +-------------- + +.. versionadded:: 6.0 + +Defines a reusable template fragment that can be rendered multiple times within +the same template or accessed directly via :ref:`template loading or inclusion +`. + +Usage: + +.. code-block:: html+django + + {% partialdef partial_name %} + {# Reusable content. #} + {% endpartialdef %} + +The ``partial_name`` argument is required and must be a valid template +identifier. + +In the following example, a new fragment named ``card`` is defined: + +.. code-block:: html+django + + {% partialdef card %} +
+

{{ title }}

+

{{ content }}

+
+ {% endpartialdef %} + +This partial can then be rendered using the :ttag:`partial` tag: + +.. code-block:: html+django + + {% partial card %} + {% partial card %} + +To :ref:`render a fragment immediately in place `, +use the ``inline`` option. The partial is still stored and can be reused later. + .. templatetag:: querystring ``querystring`` diff --git a/docs/ref/templates/language.txt b/docs/ref/templates/language.txt index 16a3682086..e8812666fa 100644 --- a/docs/ref/templates/language.txt +++ b/docs/ref/templates/language.txt @@ -475,6 +475,150 @@ it also defines the content that fills the hole in the *parent*. If there were two similarly-named :ttag:`block` tags in a template, that template's parent wouldn't know which one of the blocks' content to use. +.. _template-partials: + +Template partials +================= + +.. versionadded:: 6.0 + +Template partials define reusable template fragments within a template file. +They are self-contained components that can be rendered multiple times within +the same template, helping to avoid repetition and maintain consistent output. + +Basic syntax +------------ + +A partial can be defined using the :ttag:`partialdef` tag: + +.. code-block:: html+django + :caption: ``authors.html`` + + {% partialdef user-info %} +
+

{{ user.name }}

+

{{ user.bio }}

+
+ {% endpartialdef %} + +For extra readability, the name can be included in the ``{% endpartialdef %}`` +closing tag: + +.. code-block:: html+django + + {% partialdef user-info %} + {# ... #} + {% endpartialdef user-info %} + +The template fragment can be rendered using the :ttag:`partial` tag: + +.. code-block:: html+django + + {% partial user-info %} + +Fragment reuse +-------------- + +A template fragment can be reused multiple times: + +.. code-block:: html+django + :caption: ``authors.html`` + + {% block content %} +

Authors

+ {% for user in authors %} + {% partial user-info %} + {% endfor %} + +

Editors

+ {% for user in editors %} + {% partial user-info %} + {% endfor %} + {% endblock %} + +The partial content is rendered each time the named partial is used, with the +current template context. + +.. _template-partials-inline: + +Inline partials +--------------- + +A template fragment can be defined and rendered in-place using the ``inline`` +argument. This defines the partial for later reuse while also rendering it +immediately at its definition: + +.. code-block:: html+django + + {# Define and render immediately. #} + {% partialdef user-info inline %} +
+

{{ user.name }}

+

{{ user.bio }}

+
+ {% endpartialdef %} + + {# Other page content here. #} + + {# Reuse later elsewhere in the template. #} + + +.. _template-partials-direct-access: + +Accessing partials directly +--------------------------- + +Template fragments defined with :ttag:`partialdef` can be accessed directly via +template loading or inclusion using the ``template.html#partial_name`` syntax. + +For example, using the :func:`~django.shortcuts.render` shortcut, the following +code renders only the partial named ``user-info`` defined in the +``authors.html`` template:: + + from django.contrib.auth.models import User + from django.shortcuts import get_object_or_404, render + + + def user_info_partial(request, user_id): + user = get_object_or_404(User, id=user_id) + return render(request, "authors.html#user-info", {"user": user}) + +This approach is particularly useful for AJAX-style requests that update only +specific portions of a page with the rendered template fragment. + +Template partials can also be included using the :ttag:`include` template tag +with the same ``#`` directive: + +.. code-block:: html+django + + {% include "authors.html#user-info" %} + +Context handling +---------------- + +Template partials are rendered with the current template context. They work as +expected in loops and with context variables: + +.. code-block:: html+django + + {% for user in users %} + {% partial user-info %} + {% endfor %} + +Context variables can be adjusted using the :ttag:`with` tag: + +.. code-block:: html+django + + {% with user=featured_author %} +

Featured Author of the Month

+ {% partial user-info %} + {% endwith %} + .. _next section: #automatic-html-escaping .. _automatic-html-escaping: diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index 0c1a2c87eb..3e250706ec 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -91,6 +91,21 @@ Notably, the return type of the :class:`EmailMessage.message() previous ``SafeMIMEText`` and ``SafeMIMEMultipart`` return types, but is not an instance of those now-deprecated classes. +Template Partials +----------------- + +The :ref:`Django Template Language ` now supports +:ref:`template partials ` , making it easier to encapsulate +and reuse small named fragments within a template file. The new tags +:ttag:`{% partialdef %} ` and :ttag:`{% partial %} ` +define a partial and render it, respectively. + +Partials can also be referenced using the ``template_name#partial_name`` syntax +with :func:`~django.template.Engine.get_template`, +:func:`~django.shortcuts.render`, :ttag:`{% include %}`, and other +template-loading tools, enabling more modular and maintainable templates +without needing to split components into separate files. + Minor features -------------- diff --git a/docs/topics/templates.txt b/docs/topics/templates.txt index df55cccb06..1b5559db5a 100644 --- a/docs/topics/templates.txt +++ b/docs/topics/templates.txt @@ -306,6 +306,33 @@ The ``django.template.loader`` module defines two functions to load templates. If you want to restrict the search to a particular template engine, pass the engine's :setting:`NAME ` in the ``using`` argument. + **Partial loading:** + + When using the :class:`~django.template.backends.django.DjangoTemplates` + backend, a specific fragment from a template can be loaded by name. This + fragment should have been previously defined using the :ttag:`partialdef` + template tag:: + + from django.template.loader import get_template + + # Load an entire template. + template = get_template("template.html") + + # Load a specific fragment from a template. + partial = get_template("template.html#partial_name") + + When loading a partial, the returned object behaves like a regular + ``Template`` but contains only the partial's content. + + See :ref:`template-partials` for more information about defining and using + template fragments. + + .. versionchanged:: 6.0 + + Support for loading template partials when using the + :class:`~django.template.backends.django.DjangoTemplates` backend was + added. + .. function:: select_template(template_name_list, using=None) ``select_template()`` is just like ``get_template()``, except it takes a diff --git a/tests/template_tests/syntax_tests/test_partials.py b/tests/template_tests/syntax_tests/test_partials.py new file mode 100644 index 0000000000..a2cd3ae96a --- /dev/null +++ b/tests/template_tests/syntax_tests/test_partials.py @@ -0,0 +1,652 @@ +from django.template import ( + Context, + TemplateDoesNotExist, + TemplateSyntaxError, + VariableDoesNotExist, +) +from django.template.base import Token, TokenType +from django.test import SimpleTestCase +from django.views.debug import ExceptionReporter + +from ..utils import setup + +partial_templates = { + "partial_base.html": ( + "
{% block main %}Default main content.{% endblock main %}
" + ), + "partial_included.html": ( + "INCLUDED TEMPLATE START\n" + "{% partialdef included-partial %}\n" + "THIS IS CONTENT FROM THE INCLUDED PARTIAL\n" + "{% endpartialdef %}\n\n" + "Now using the partial: {% partial included-partial %}\n" + "INCLUDED TEMPLATE END\n" + ), +} + +valid_partialdef_names = ( + "dot.in.name", + "'space in name'", + "exclamation!", + "@at", + "slash/something", + "inline", + "inline-inline", + "INLINE" "with+plus", + "with&", + "with%percent", + "with,comma", + "with:colon", + "with;semicolon", + "[brackets]", + "(parens)", + "{curly}", +) + + +def gen_partial_template(name, *args, **kwargs): + if args or kwargs: + extra = " ".join((args, *("{k}={v}" for k, v in kwargs.items()))) + " " + else: + extra = "" + return ( + f"{{% partialdef {name} {extra}%}}TEST with {name}!{{% endpartialdef %}}" + f"{{% partial {name} %}}" + ) + + +class PartialTagTests(SimpleTestCase): + libraries = {"bad_tag": "template_tests.templatetags.bad_tag"} + + @setup({name: gen_partial_template(name) for name in valid_partialdef_names}) + def test_valid_partialdef_names(self): + for template_name in valid_partialdef_names: + with self.subTest(template_name=template_name): + output = self.engine.render_to_string(template_name) + self.assertEqual(output, f"TEST with {template_name}!") + + @setup( + { + "basic": ( + "{% partialdef testing-name %}" + "HERE IS THE TEST CONTENT" + "{% endpartialdef %}" + "{% partial testing-name %}" + ), + "basic_inline": ( + "{% partialdef testing-name inline %}" + "HERE IS THE TEST CONTENT" + "{% endpartialdef %}" + ), + "inline_inline": ( + "{% partialdef inline inline %}" + "HERE IS THE TEST CONTENT" + "{% endpartialdef %}" + ), + "with_newlines": ( + "{% partialdef testing-name %}\n" + "HERE IS THE TEST CONTENT\n" + "{% endpartialdef testing-name %}\n" + "{% partial testing-name %}" + ), + } + ) + def test_basic_usage(self): + for template_name in ( + "basic", + "basic_inline", + "inline_inline", + "with_newlines", + ): + with self.subTest(template_name=template_name): + output = self.engine.render_to_string(template_name) + self.assertEqual(output.strip(), "HERE IS THE TEST CONTENT") + + @setup( + { + "inline_partial_with_context": ( + "BEFORE\n" + "{% partialdef testing-name inline %}" + "HERE IS THE TEST CONTENT" + "{% endpartialdef %}\n" + "AFTER" + ) + } + ) + def test_partial_inline_only_with_before_and_after_content(self): + output = self.engine.render_to_string("inline_partial_with_context") + self.assertEqual(output.strip(), "BEFORE\nHERE IS THE TEST CONTENT\nAFTER") + + @setup( + { + "inline_partial_explicit_end": ( + "{% partialdef testing-name inline %}" + "HERE IS THE TEST CONTENT" + "{% endpartialdef testing-name %}\n" + "{% partial testing-name %}" + ) + } + ) + def test_partial_inline_and_used_once(self): + output = self.engine.render_to_string("inline_partial_explicit_end") + self.assertEqual(output, "HERE IS THE TEST CONTENT\nHERE IS THE TEST CONTENT") + + @setup( + { + "inline_partial_with_usage": ( + "BEFORE\n" + "{% partialdef content_snippet inline %}" + "HERE IS THE TEST CONTENT" + "{% endpartialdef %}\n" + "AFTER\n" + "{% partial content_snippet %}" + ) + } + ) + def test_partial_inline_and_used_once_with_before_and_after_content(self): + output = self.engine.render_to_string("inline_partial_with_usage") + self.assertEqual( + output.strip(), + "BEFORE\nHERE IS THE TEST CONTENT\nAFTER\nHERE IS THE TEST CONTENT", + ) + + @setup( + { + "partial_used_before_definition": ( + "TEMPLATE START\n" + "{% partial testing-name %}\n" + "MIDDLE CONTENT\n" + "{% partialdef testing-name %}\n" + "THIS IS THE PARTIAL CONTENT\n" + "{% endpartialdef %}\n" + "TEMPLATE END" + ), + } + ) + def test_partial_used_before_definition(self): + output = self.engine.render_to_string("partial_used_before_definition") + expected = ( + "TEMPLATE START\n\nTHIS IS THE PARTIAL CONTENT\n\n" + "MIDDLE CONTENT\n\nTEMPLATE END" + ) + self.assertEqual(output, expected) + + @setup( + { + "partial_with_extends": ( + "{% extends 'partial_base.html' %}" + "{% partialdef testing-name %}Inside Content{% endpartialdef %}" + "{% block main %}" + "Main content with {% partial testing-name %}" + "{% endblock %}" + ), + }, + partial_templates, + ) + def test_partial_defined_outside_main_block(self): + output = self.engine.render_to_string("partial_with_extends") + self.assertIn("
Main content with Inside Content
", output) + + @setup( + { + "partial_with_extends_and_block_super": ( + "{% extends 'partial_base.html' %}" + "{% partialdef testing-name %}Inside Content{% endpartialdef %}" + "{% block main %}{{ block.super }} " + "Main content with {% partial testing-name %}" + "{% endblock %}" + ), + }, + partial_templates, + ) + def test_partial_used_with_block_super(self): + output = self.engine.render_to_string("partial_with_extends_and_block_super") + self.assertIn( + "
Default main content. Main content with Inside Content
", + output, + ) + + @setup( + { + "partial_with_include": ( + "MAIN TEMPLATE START\n" + "{% include 'partial_included.html' %}\n" + "MAIN TEMPLATE END" + ) + }, + partial_templates, + ) + def test_partial_in_included_template(self): + output = self.engine.render_to_string("partial_with_include") + expected = ( + "MAIN TEMPLATE START\nINCLUDED TEMPLATE START\n\n\n" + "Now using the partial: \n" + "THIS IS CONTENT FROM THE INCLUDED PARTIAL\n\n" + "INCLUDED TEMPLATE END\n\nMAIN TEMPLATE END" + ) + self.assertEqual(output, expected) + + @setup( + { + "partial_as_include_in_other_template": ( + "MAIN TEMPLATE START\n" + "{% include 'partial_included.html#included-partial' %}\n" + "MAIN TEMPLATE END" + ) + }, + partial_templates, + ) + def test_partial_as_include_in_template(self): + output = self.engine.render_to_string("partial_as_include_in_other_template") + expected = ( + "MAIN TEMPLATE START\n\n" + "THIS IS CONTENT FROM THE INCLUDED PARTIAL\n\n" + "MAIN TEMPLATE END" + ) + self.assertEqual(output, expected) + + @setup( + { + "nested_simple": ( + "{% extends 'base.html' %}" + "{% block content %}" + "This is my main page." + "{% partialdef outer inline %}" + " It hosts a couple of partials.\n" + " {% partialdef inner inline %}" + " And an inner one." + " {% endpartialdef inner %}" + "{% endpartialdef outer %}" + "{% endblock content %}" + ), + "use_outer": "{% include 'nested_simple#outer' %}", + "use_inner": "{% include 'nested_simple#inner' %}", + } + ) + def test_nested_partials(self): + with self.subTest(template_name="use_outer"): + output = self.engine.render_to_string("use_outer") + self.assertEqual( + [line.strip() for line in output.split("\n")], + ["It hosts a couple of partials.", "And an inner one."], + ) + with self.subTest(template_name="use_inner"): + output = self.engine.render_to_string("use_inner") + self.assertEqual(output.strip(), "And an inner one.") + + @setup( + { + "partial_undefined_name": "{% partial undefined %}", + "partial_missing_name": "{% partial %}", + "partial_closing_tag": ( + "{% partialdef testing-name %}TEST{% endpartialdef %}" + "{% partial testing-name %}{% endpartial %}" + ), + "partialdef_missing_name": "{% partialdef %}{% endpartialdef %}", + "partialdef_missing_close_tag": "{% partialdef name %}TEST", + "partialdef_opening_closing_name_mismatch": ( + "{% partialdef testing-name %}TEST{% endpartialdef invalid %}" + ), + "partialdef_invalid_name": gen_partial_template("with\nnewline"), + "partialdef_extra_params": ( + "{% partialdef testing-name inline extra %}TEST{% endpartialdef %}" + ), + "partialdef_duplicated_names": ( + "{% partialdef testing-name %}TEST{% endpartialdef %}" + "{% partialdef testing-name %}TEST{% endpartialdef %}" + "{% partial testing-name %}" + ), + "partialdef_duplicated_nested_names": ( + "{% partialdef testing-name %}" + "TEST" + "{% partialdef testing-name %}TEST{% endpartialdef %}" + "{% endpartialdef %}" + "{% partial testing-name %}" + ), + }, + ) + def test_basic_parse_errors(self): + for template_name, error_msg in ( + ( + "partial_undefined_name", + "Partial 'undefined' is not defined in the current template.", + ), + ("partial_missing_name", "'partial' tag requires a single argument"), + ("partial_closing_tag", "Invalid block tag on line 1: 'endpartial'"), + ("partialdef_missing_name", "'partialdef' tag requires a name"), + ("partialdef_missing_close_tag", "Unclosed tag on line 1: 'partialdef'"), + ( + "partialdef_opening_closing_name_mismatch", + "expected 'endpartialdef' or 'endpartialdef testing-name'.", + ), + ("partialdef_invalid_name", "Invalid block tag on line 3: 'endpartialdef'"), + ("partialdef_extra_params", "'partialdef' tag takes at most 2 arguments"), + ( + "partialdef_duplicated_names", + "Partial 'testing-name' is already defined in the " + "'partialdef_duplicated_names' template.", + ), + ( + "partialdef_duplicated_nested_names", + "Partial 'testing-name' is already defined in the " + "'partialdef_duplicated_nested_names' template.", + ), + ): + with ( + self.subTest(template_name=template_name), + self.assertRaisesMessage(TemplateSyntaxError, error_msg), + ): + self.engine.render_to_string(template_name) + + @setup( + { + "with_params": ( + "{% partialdef testing-name inline=true %}TEST{% endpartialdef %}" + ), + "uppercase": "{% partialdef testing-name INLINE %}TEST{% endpartialdef %}", + } + ) + def test_partialdef_invalid_inline(self): + error_msg = "The 'inline' argument does not have any parameters" + for template_name in ("with_params", "uppercase"): + with ( + self.subTest(template_name=template_name), + self.assertRaisesMessage(TemplateSyntaxError, error_msg), + ): + self.engine.render_to_string(template_name) + + @setup( + { + "partial_broken_unclosed": ( + "
Before partial
" + "{% partialdef unclosed_partial %}" + "

This partial has no closing tag

" + "
After partial content
" + ) + } + ) + def test_broken_partial_unclosed_exception_info(self): + with self.assertRaises(TemplateSyntaxError) as cm: + self.engine.get_template("partial_broken_unclosed") + + self.assertIn("endpartialdef", str(cm.exception)) + self.assertIn("Unclosed tag", str(cm.exception)) + + reporter = ExceptionReporter(None, cm.exception.__class__, cm.exception, None) + traceback_data = reporter.get_traceback_data() + + exception_value = str(traceback_data.get("exception_value", "")) + self.assertIn("Unclosed tag", exception_value) + + @setup( + { + "partial_with_variable_error": ( + "

Title

\n" + "{% partialdef testing-name %}\n" + "

{{ nonexistent|default:alsonotthere }}

\n" + "{% endpartialdef %}\n" + "

Sub Title

\n" + "{% partial testing-name %}\n" + ), + } + ) + def test_partial_runtime_exception_has_debug_info(self): + template = self.engine.get_template("partial_with_variable_error") + context = Context({}) + + if hasattr(self.engine, "string_if_invalid") and self.engine.string_if_invalid: + output = template.render(context) + # The variable should be replaced with INVALID + self.assertIn("INVALID", output) + else: + with self.assertRaises(VariableDoesNotExist) as cm: + template.render(context) + + if self.engine.debug: + exc_info = cm.exception.template_debug + + self.assertEqual( + exc_info["during"], "{{ nonexistent|default:alsonotthere }}" + ) + self.assertEqual(exc_info["line"], 3) + self.assertEqual(exc_info["name"], "partial_with_variable_error") + self.assertIn("Failed lookup", exc_info["message"]) + + @setup( + { + "partial_exception_info_test": ( + "

Title

\n" + "{% partialdef testing-name %}\n" + "

Content

\n" + "{% endpartialdef %}\n" + ), + } + ) + def test_partial_template_get_exception_info_delegation(self): + if self.engine.debug: + template = self.engine.get_template("partial_exception_info_test") + + partial_template = template.extra_data["partials"]["testing-name"] + + test_exc = Exception("Test exception") + token = Token( + token_type=TokenType.VAR, + contents="test", + position=(0, 4), + ) + + exc_info = partial_template.get_exception_info(test_exc, token) + self.assertIn("message", exc_info) + self.assertIn("line", exc_info) + self.assertIn("name", exc_info) + self.assertEqual(exc_info["name"], "partial_exception_info_test") + self.assertEqual(exc_info["message"], "Test exception") + + @setup( + { + "partial_with_undefined_reference": ( + "

Header

\n" + "{% partial undefined %}\n" + "

After undefined partial

\n" + ), + } + ) + def test_undefined_partial_exception_info(self): + template = self.engine.get_template("partial_with_undefined_reference") + with self.assertRaises(TemplateSyntaxError) as cm: + template.render(Context()) + + self.assertIn("undefined", str(cm.exception)) + self.assertIn("is not defined", str(cm.exception)) + + if self.engine.debug: + exc_debug = cm.exception.template_debug + + self.assertEqual(exc_debug["during"], "{% partial undefined %}") + self.assertEqual(exc_debug["line"], 2) + self.assertEqual(exc_debug["name"], "partial_with_undefined_reference") + self.assertIn("undefined", exc_debug["message"]) + + @setup( + { + "existing_template": ( + "

Header

This template has no partials defined

" + ), + } + ) + def test_undefined_partial_exception_info_template_does_not_exist(self): + with self.assertRaises(TemplateDoesNotExist) as cm: + self.engine.get_template("existing_template#undefined") + + self.assertIn("undefined", str(cm.exception)) + + @setup( + { + "partial_with_syntax_error": ( + "

Title

\n" + "{% partialdef syntax_error_partial %}\n" + " {% if user %}\n" + "

User: {{ user.name }}

\n" + " {% endif\n" + "

Missing closing tag above

\n" + "{% endpartialdef %}\n" + "{% partial syntax_error_partial %}\n" + ), + } + ) + def test_partial_with_syntax_error_exception_info(self): + with self.assertRaises(TemplateSyntaxError) as cm: + self.engine.get_template("partial_with_syntax_error") + + self.assertIn("endif", str(cm.exception).lower()) + + if self.engine.debug: + exc_debug = cm.exception.template_debug + + self.assertIn("endpartialdef", exc_debug["during"]) + self.assertEqual(exc_debug["name"], "partial_with_syntax_error") + self.assertIn("endif", exc_debug["message"].lower()) + + @setup( + { + "partial_with_runtime_error": ( + "

Title

\n" + "{% load bad_tag %}\n" + "{% partialdef runtime_error_partial %}\n" + "

This will raise an error:

\n" + " {% badsimpletag %}\n" + "{% endpartialdef %}\n" + "{% partial runtime_error_partial %}\n" + ), + } + ) + def test_partial_runtime_error_exception_info(self): + template = self.engine.get_template("partial_with_runtime_error") + context = Context() + + with self.assertRaises(RuntimeError) as cm: + template.render(context) + + if self.engine.debug: + exc_debug = cm.exception.template_debug + + self.assertIn("badsimpletag", exc_debug["during"]) + self.assertEqual(exc_debug["line"], 5) # Line 5 is where badsimpletag is + self.assertEqual(exc_debug["name"], "partial_with_runtime_error") + self.assertIn("bad simpletag", exc_debug["message"]) + + @setup( + { + "nested_partial_with_undefined_var": ( + "

Title

\n" + "{% partialdef outer_partial %}\n" + '
\n' + " {% partialdef inner_partial %}\n" + "

{{ undefined_var }}

\n" + " {% endpartialdef %}\n" + " {% partial inner_partial %}\n" + "
\n" + "{% endpartialdef %}\n" + "{% partial outer_partial %}\n" + ), + } + ) + def test_nested_partial_error_exception_info(self): + template = self.engine.get_template("nested_partial_with_undefined_var") + context = Context() + output = template.render(context) + + # When string_if_invalid is set, it will show INVALID + # When not set, undefined variables just render as empty string + if hasattr(self.engine, "string_if_invalid") and self.engine.string_if_invalid: + self.assertIn("INVALID", output) + else: + self.assertIn("

", output) + self.assertIn("

", output) + + @setup( + { + "parent.html": ( + "\n" + "\n" + "{% block title %}Default Title{% endblock %}\n" + "\n" + " {% block content %}{% endblock %}\n" + "\n" + "\n" + ), + "child.html": ( + "{% extends 'parent.html' %}\n" + "{% block content %}\n" + " {% partialdef content_partial %}\n" + "

{{ missing_variable|undefined_filter }}

\n" + " {% endpartialdef %}\n" + " {% partial content_partial %}\n" + "{% endblock %}\n" + ), + } + ) + def test_partial_in_extended_template_error(self): + with self.assertRaises(TemplateSyntaxError) as cm: + self.engine.get_template("child.html") + + self.assertIn("undefined_filter", str(cm.exception)) + + if self.engine.debug: + exc_debug = cm.exception.template_debug + + self.assertIn("undefined_filter", exc_debug["during"]) + self.assertEqual(exc_debug["name"], "child.html") + self.assertIn("undefined_filter", exc_debug["message"]) + + @setup( + { + "partial_broken_nesting": ( + "
Before partial
\n" + "{% partialdef outer %}\n" + "{% partialdef inner %}...{% endpartialdef outer %}\n" + "{% endpartialdef inner %}\n" + "
After partial content
" + ) + } + ) + def test_broken_partial_nesting(self): + with self.assertRaises(TemplateSyntaxError) as cm: + self.engine.get_template("partial_broken_nesting") + + self.assertIn("endpartialdef", str(cm.exception)) + self.assertIn("Invalid block tag", str(cm.exception)) + self.assertIn("'endpartialdef inner'", str(cm.exception)) + + reporter = ExceptionReporter(None, cm.exception.__class__, cm.exception, None) + traceback_data = reporter.get_traceback_data() + + exception_value = str(traceback_data.get("exception_value", "")) + self.assertIn("Invalid block tag", exception_value) + self.assertIn("'endpartialdef inner'", str(cm.exception)) + + @setup( + { + "partial_broken_nesting_mixed": ( + "
Before partial
\n" + "{% partialdef outer %}\n" + "{% partialdef inner %}...{% endpartialdef %}\n" + "{% endpartialdef inner %}\n" + "
After partial content
" + ) + } + ) + def test_broken_partial_nesting_mixed(self): + with self.assertRaises(TemplateSyntaxError) as cm: + self.engine.get_template("partial_broken_nesting_mixed") + + self.assertIn("endpartialdef", str(cm.exception)) + self.assertIn("Invalid block tag", str(cm.exception)) + self.assertIn("'endpartialdef outer'", str(cm.exception)) + + reporter = ExceptionReporter(None, cm.exception.__class__, cm.exception, None) + traceback_data = reporter.get_traceback_data() + + exception_value = str(traceback_data.get("exception_value", "")) + self.assertIn("Invalid block tag", exception_value) + self.assertIn("'endpartialdef outer'", str(cm.exception)) diff --git a/tests/template_tests/templates/partial_base.html b/tests/template_tests/templates/partial_base.html new file mode 100644 index 0000000000..c23da046f5 --- /dev/null +++ b/tests/template_tests/templates/partial_base.html @@ -0,0 +1,2 @@ +{% block main %} +{% endblock main %} diff --git a/tests/template_tests/templates/partial_child.html b/tests/template_tests/templates/partial_child.html new file mode 100644 index 0000000000..ac059dc87e --- /dev/null +++ b/tests/template_tests/templates/partial_child.html @@ -0,0 +1,9 @@ +{% extends 'partial_base.html' %} + +{% partialdef extra-content %} +Extra Content +{% endpartialdef %} + +{% block main %} +Main Content +{% endblock %} diff --git a/tests/template_tests/templates/partial_examples.html b/tests/template_tests/templates/partial_examples.html new file mode 100644 index 0000000000..61ffff4005 --- /dev/null +++ b/tests/template_tests/templates/partial_examples.html @@ -0,0 +1,15 @@ +{% partialdef test-partial %} +TEST-PARTIAL-CONTENT +{% endpartialdef %} + +{% block main %} +BEGINNING +{% partial test-partial %} +MIDDLE +{% partial test-partial %} +END +{% endblock main %} + +{% partialdef inline-partial inline %} +INLINE-CONTENT +{% endpartialdef %} diff --git a/tests/template_tests/test_partials.py b/tests/template_tests/test_partials.py new file mode 100644 index 0000000000..8f1a74b2ec --- /dev/null +++ b/tests/template_tests/test_partials.py @@ -0,0 +1,413 @@ +import os +from unittest import mock + +from django.http import HttpResponse +from django.template import ( + Context, + Origin, + Template, + TemplateDoesNotExist, + TemplateSyntaxError, + engines, +) +from django.template.backends.django import DjangoTemplates +from django.template.loader import render_to_string +from django.test import TestCase, override_settings +from django.urls import path, reverse + +engine = engines["django"] + + +class PartialTagsTests(TestCase): + + def test_invalid_template_name_raises_template_does_not_exist(self): + for template_name in [123, None, "", "#", "#name"]: + with ( + self.subTest(template_name=template_name), + self.assertRaisesMessage(TemplateDoesNotExist, str(template_name)), + ): + engine.get_template(template_name) + + def test_template_source_is_correct(self): + partial = engine.get_template("partial_examples.html#test-partial") + self.assertEqual( + partial.template.source, + "{% partialdef test-partial %}\nTEST-PARTIAL-CONTENT\n{% endpartialdef %}", + ) + + def test_template_source_inline_is_correct(self): + partial = engine.get_template("partial_examples.html#inline-partial") + self.assertEqual( + partial.template.source, + "{% partialdef inline-partial inline %}\nINLINE-CONTENT\n" + "{% endpartialdef %}", + ) + + def test_full_template_from_loader(self): + template = engine.get_template("partial_examples.html") + rendered = template.render({}) + + # Check the partial was rendered twice + self.assertEqual(2, rendered.count("TEST-PARTIAL-CONTENT")) + self.assertEqual(1, rendered.count("INLINE-CONTENT")) + + def test_chained_exception_forwarded(self): + with self.assertRaises(TemplateDoesNotExist) as ctx: + engine.get_template("not_there.html#not-a-partial") + + exception = ctx.exception + self.assertGreater(len(exception.tried), 0) + origin, _ = exception.tried[0] + self.assertEqual(origin.template_name, "not_there.html") + + def test_partials_use_cached_loader_when_configured(self): + template_dir = os.path.join(os.path.dirname(__file__), "templates") + backend = DjangoTemplates( + { + "NAME": "django", + "DIRS": [template_dir], + "APP_DIRS": False, + "OPTIONS": { + "loaders": [ + ( + "django.template.loaders.cached.Loader", + ["django.template.loaders.filesystem.Loader"], + ), + ], + }, + } + ) + cached_loader = backend.engine.template_loaders[0] + filesystem_loader = cached_loader.loaders[0] + + with mock.patch.object( + filesystem_loader, "get_contents", wraps=filesystem_loader.get_contents + ) as mock_get_contents: + full_template = backend.get_template("partial_examples.html") + self.assertIn("TEST-PARTIAL-CONTENT", full_template.render({})) + + partial_template = backend.get_template( + "partial_examples.html#test-partial" + ) + self.assertEqual( + "TEST-PARTIAL-CONTENT", partial_template.render({}).strip() + ) + + mock_get_contents.assert_called_once() + + def test_context_available_in_response_for_partial_template(self): + def sample_view(request): + return HttpResponse( + render_to_string("partial_examples.html#test-partial", {"foo": "bar"}) + ) + + class PartialUrls: + urlpatterns = [path("sample/", sample_view, name="sample-view")] + + with override_settings(ROOT_URLCONF=PartialUrls): + response = self.client.get(reverse("sample-view")) + + self.assertContains(response, "TEST-PARTIAL-CONTENT") + self.assertEqual(response.context.get("foo"), "bar") + + def test_response_with_multiple_parts(self): + context = {} + template_partials = ["partial_child.html", "partial_child.html#extra-content"] + + response_whole_content_at_once = HttpResponse( + "".join( + render_to_string(template_name, context) + for template_name in template_partials + ) + ) + + response_with_multiple_writes = HttpResponse() + for template_name in template_partials: + response_with_multiple_writes.write( + render_to_string(template_name, context) + ) + + response_with_generator = HttpResponse( + render_to_string(template_name, context) + for template_name in template_partials + ) + + for label, response in [ + ("response_whole_content_at_once", response_whole_content_at_once), + ("response_with_multiple_writes", response_with_multiple_writes), + ("response_with_generator", response_with_generator), + ]: + with self.subTest(response=label): + self.assertIn(b"Main Content", response.content) + self.assertIn(b"Extra Content", response.content) + + def test_partial_engine_assignment_with_real_template(self): + template_with_partial = engine.get_template( + "partial_examples.html#test-partial" + ) + self.assertEqual(template_with_partial.template.engine, engine.engine) + rendered_content = template_with_partial.render({}) + self.assertEqual("TEST-PARTIAL-CONTENT", rendered_content.strip()) + + +class RobustPartialHandlingTests(TestCase): + + def override_get_template(self, **kwargs): + class TemplateWithCustomAttrs: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def render(self, context): + return "rendered content" + + template = TemplateWithCustomAttrs(**kwargs) + origin = self.id() + return mock.patch.object( + engine.engine, + "find_template", + return_value=(template, origin), + ) + + def test_template_without_extra_data_attribute(self): + partial_name = "some_partial_name" + with ( + self.override_get_template(), + self.assertRaisesMessage(TemplateDoesNotExist, partial_name), + ): + engine.get_template(f"some_template.html#{partial_name}") + + def test_template_extract_extra_data_robust(self): + partial_name = "some_partial_name" + for extra_data in ( + None, + 0, + [], + {}, + {"wrong-key": {}}, + {"partials": None}, + {"partials": {}}, + {"partials": []}, + {"partials": 0}, + ): + with ( + self.subTest(extra_data=extra_data), + self.override_get_template(extra_data=extra_data), + self.assertRaisesMessage(TemplateDoesNotExist, partial_name), + ): + engine.get_template(f"template.html#{partial_name}") + + def test_nested_partials_rendering_with_context(self): + template_source = """ + {% partialdef outer inline %} + Hello {{ name }}! + {% partialdef inner inline %} + Your age is {{ age }}. + {% endpartialdef inner %} + Nice to meet you. + {% endpartialdef outer %} + """ + template = Template(template_source, origin=Origin(name="template.html")) + + context = Context({"name": "Alice", "age": 25}) + rendered = template.render(context) + + self.assertIn("Hello Alice!", rendered) + self.assertIn("Your age is 25.", rendered) + self.assertIn("Nice to meet you.", rendered) + + +class FindPartialSourceTests(TestCase): + + def test_find_partial_source_success(self): + template = engine.get_template("partial_examples.html").template + partial_proxy = template.extra_data["partials"]["test-partial"] + + expected = """{% partialdef test-partial %} +TEST-PARTIAL-CONTENT +{% endpartialdef %}""" + self.assertEqual(partial_proxy.source.strip(), expected.strip()) + + def test_find_partial_source_with_inline(self): + template = engine.get_template("partial_examples.html").template + partial_proxy = template.extra_data["partials"]["inline-partial"] + + expected = """{% partialdef inline-partial inline %} +INLINE-CONTENT +{% endpartialdef %}""" + self.assertEqual(partial_proxy.source.strip(), expected.strip()) + + def test_find_partial_source_nonexistent_partial(self): + template = engine.get_template("partial_examples.html").template + partial_proxy = template.extra_data["partials"]["test-partial"] + + result = partial_proxy.find_partial_source( + template.source, "nonexistent-partial" + ) + self.assertEqual(result, "") + + def test_find_partial_source_empty_partial(self): + template_source = "{% partialdef empty %}{% endpartialdef %}" + template = Template(template_source) + partial_proxy = template.extra_data["partials"]["empty"] + + result = partial_proxy.find_partial_source(template_source, "empty") + self.assertEqual(result, "{% partialdef empty %}{% endpartialdef %}") + + def test_find_partial_source_multiple_consecutive_partials(self): + + template_source = ( + "{% partialdef empty %}{% endpartialdef %}" + "{% partialdef other %}...{% endpartialdef %}" + ) + template = Template(template_source) + + empty_proxy = template.extra_data["partials"]["empty"] + other_proxy = template.extra_data["partials"]["other"] + + empty_result = empty_proxy.find_partial_source(template_source, "empty") + self.assertEqual(empty_result, "{% partialdef empty %}{% endpartialdef %}") + + other_result = other_proxy.find_partial_source(template_source, "other") + self.assertEqual(other_result, "{% partialdef other %}...{% endpartialdef %}") + + def test_partials_with_duplicate_names(self): + test_cases = [ + ( + "nested", + """ + {% partialdef duplicate %}{% partialdef duplicate %} + CONTENT + {% endpartialdef %}{% endpartialdef %} + """, + ), + ( + "conditional", + """ + {% if ... %} + {% partialdef duplicate %} + CONTENT + {% endpartialdef %} + {% else %} + {% partialdef duplicate %} + OTHER-CONTENT + {% endpartialdef %} + {% endif %} + """, + ), + ] + + for test_name, template_source in test_cases: + with self.subTest(test_name=test_name): + with self.assertRaisesMessage( + TemplateSyntaxError, + "Partial 'duplicate' is already defined in the " + "'template.html' template.", + ): + Template(template_source, origin=Origin(name="template.html")) + + def test_find_partial_source_supports_named_end_tag(self): + template_source = "{% partialdef thing %}CONTENT{% endpartialdef thing %}" + template = Template(template_source) + partial_proxy = template.extra_data["partials"]["thing"] + + result = partial_proxy.find_partial_source(template_source, "thing") + self.assertEqual( + result, "{% partialdef thing %}CONTENT{% endpartialdef thing %}" + ) + + def test_find_partial_source_supports_nested_partials(self): + template_source = ( + "{% partialdef outer %}" + "{% partialdef inner %}...{% endpartialdef %}" + "{% endpartialdef %}" + ) + template = Template(template_source) + + empty_proxy = template.extra_data["partials"]["outer"] + other_proxy = template.extra_data["partials"]["inner"] + + outer_result = empty_proxy.find_partial_source(template_source, "outer") + self.assertEqual( + outer_result, + ( + "{% partialdef outer %}{% partialdef inner %}" + "...{% endpartialdef %}{% endpartialdef %}" + ), + ) + + inner_result = other_proxy.find_partial_source(template_source, "inner") + self.assertEqual(inner_result, "{% partialdef inner %}...{% endpartialdef %}") + + def test_find_partial_source_supports_nested_partials_and_named_end_tags(self): + template_source = ( + "{% partialdef outer %}" + "{% partialdef inner %}...{% endpartialdef inner %}" + "{% endpartialdef outer %}" + ) + template = Template(template_source) + + empty_proxy = template.extra_data["partials"]["outer"] + other_proxy = template.extra_data["partials"]["inner"] + + outer_result = empty_proxy.find_partial_source(template_source, "outer") + self.assertEqual( + outer_result, + ( + "{% partialdef outer %}{% partialdef inner %}" + "...{% endpartialdef inner %}{% endpartialdef outer %}" + ), + ) + + inner_result = other_proxy.find_partial_source(template_source, "inner") + self.assertEqual( + inner_result, "{% partialdef inner %}...{% endpartialdef inner %}" + ) + + def test_find_partial_source_supports_nested_partials_and_mixed_end_tags_1(self): + template_source = ( + "{% partialdef outer %}" + "{% partialdef inner %}...{% endpartialdef %}" + "{% endpartialdef outer %}" + ) + template = Template(template_source) + + empty_proxy = template.extra_data["partials"]["outer"] + other_proxy = template.extra_data["partials"]["inner"] + + outer_result = empty_proxy.find_partial_source(template_source, "outer") + self.assertEqual( + outer_result, + ( + "{% partialdef outer %}{% partialdef inner %}" + "...{% endpartialdef %}{% endpartialdef outer %}" + ), + ) + + inner_result = other_proxy.find_partial_source(template_source, "inner") + self.assertEqual(inner_result, "{% partialdef inner %}...{% endpartialdef %}") + + def test_find_partial_source_supports_nested_partials_and_mixed_end_tags_2(self): + template_source = ( + "{% partialdef outer %}" + "{% partialdef inner %}...{% endpartialdef inner %}" + "{% endpartialdef %}" + ) + template = Template(template_source) + + empty_proxy = template.extra_data["partials"]["outer"] + other_proxy = template.extra_data["partials"]["inner"] + + outer_result = empty_proxy.find_partial_source(template_source, "outer") + self.assertEqual( + outer_result, + ( + "{% partialdef outer %}{% partialdef inner %}" + "...{% endpartialdef inner %}{% endpartialdef %}" + ), + ) + + inner_result = other_proxy.find_partial_source(template_source, "inner") + self.assertEqual( + inner_result, "{% partialdef inner %}...{% endpartialdef inner %}" + ) diff --git a/tests/utils_tests/test_datastructures.py b/tests/utils_tests/test_datastructures.py index d5d90b68fc..460ff0042e 100644 --- a/tests/utils_tests/test_datastructures.py +++ b/tests/utils_tests/test_datastructures.py @@ -9,6 +9,7 @@ import pickle from django.test import SimpleTestCase from django.utils.datastructures import ( CaseInsensitiveMapping, + DeferredSubDict, DictWrapper, ImmutableList, MultiValueDict, @@ -367,3 +368,42 @@ class CaseInsensitiveMappingTests(SimpleTestCase): with self.assertRaisesMessage(TypeError, msg): self.dict1["New Key"] = 1 self.assertEqual(len(self.dict1), 2) + + +class DeferredSubDictTests(SimpleTestCase): + def test_basic(self): + parent = { + "settings": {"theme": "dark", "language": "en"}, + "config": {"enabled": True, "timeout": 30}, + } + sub = DeferredSubDict(parent, "settings") + self.assertEqual(sub["theme"], "dark") + self.assertEqual(sub["language"], "en") + with self.assertRaises(KeyError): + sub["enabled"] + + def test_reflects_changes_in_parent(self): + parent = {"settings": {"theme": "dark"}} + sub = DeferredSubDict(parent, "settings") + parent["settings"]["theme"] = "light" + self.assertEqual(sub["theme"], "light") + parent["settings"]["mode"] = "tight" + self.assertEqual(sub["mode"], "tight") + + def test_missing_deferred_key_raises_keyerror(self): + parent = {"settings": {"theme": "dark"}} + sub = DeferredSubDict(parent, "nonexistent") + with self.assertRaises(KeyError): + sub["anything"] + + def test_missing_child_key_raises_keyerror(self): + parent = {"settings": {"theme": "dark"}} + sub = DeferredSubDict(parent, "settings") + with self.assertRaises(KeyError): + sub["nonexistent"] + + def test_child_not_a_dict_raises_typeerror(self): + parent = {"bad": "not_a_dict"} + sub = DeferredSubDict(parent, "bad") + with self.assertRaises(TypeError): + sub["any_key"]