summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorfarhan <farhanalirazaazeemi@gmail.com>2025-06-03 10:32:34 +0500
committernessita <124304+nessita@users.noreply.github.com>2025-08-14 21:53:14 -0300
commit5e06b970956af4854e970e74990cb971ba31c96b (patch)
tree59fb79f5e255298625af1ad072ad4026e64f582d
parentfda3c1712a1eb7b20dfc91e6c9abae32bd64d081 (diff)
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 <carlton@noumenal.es> Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> Co-authored-by: Nick Pope <nick@nickpope.me.uk>
-rw-r--r--django/template/__init__.py1
-rw-r--r--django/template/base.py56
-rw-r--r--django/template/defaulttags.py96
-rw-r--r--django/template/engine.py22
-rw-r--r--django/test/utils.py5
-rw-r--r--django/utils/datastructures.py18
-rw-r--r--docs/ref/templates/builtins.txt74
-rw-r--r--docs/ref/templates/language.txt144
-rw-r--r--docs/releases/6.0.txt15
-rw-r--r--docs/topics/templates.txt27
-rw-r--r--tests/template_tests/syntax_tests/test_partials.py652
-rw-r--r--tests/template_tests/templates/partial_base.html2
-rw-r--r--tests/template_tests/templates/partial_child.html9
-rw-r--r--tests/template_tests/templates/partial_examples.html15
-rw-r--r--tests/template_tests/test_partials.py413
-rw-r--r--tests/utils_tests/test_datastructures.py40
16 files changed, 1587 insertions, 2 deletions
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 = "<unknown source>"
# than instantiating SimpleLazyObject with _lazy_re_compile().
tag_re = re.compile(r"({%.*?%}|{{.*?}}|{#.*?#})")
+combined_partial_re = re.compile(
+ r"{%\s*partialdef\s+(?P<name>[\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 %}
+ <button>Submit</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
+<template-partials-direct-access>`.
+
+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 %}
+ <div class="card">
+ <h3>{{ title }}</h3>
+ <p>{{ content }}</p>
+ </div>
+ {% 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 <template-partials-inline>`,
+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 %}
+ <div id="user-info-{{ user.username }}">
+ <h3>{{ user.name }}</h3>
+ <p>{{ user.bio }}</p>
+ </div>
+ {% 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 %}
+ <h2>Authors</h2>
+ {% for user in authors %}
+ {% partial user-info %}
+ {% endfor %}
+
+ <h2>Editors</h2>
+ {% 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 %}
+ <div id="user-info-{{ user.username }}">
+ <h3>{{ user.name }}</h3>
+ <p>{{ user.bio }}</p>
+ </div>
+ {% endpartialdef %}
+
+ {# Other page content here. #}
+
+ {# Reuse later elsewhere in the template. #}
+ <section class="featured-authors">
+ <h2>Featured Authors</h2>
+ {% for user in featured %}
+ {% partial user-info %}
+ {% endfor %}
+ </section>
+
+.. _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 %}
+ <h2>Featured Author of the Month</h2>
+ {% 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 <template-language-intro>` now supports
+:ref:`template partials <template-partials>` , making it easier to encapsulate
+and reuse small named fragments within a template file. The new tags
+:ttag:`{% partialdef %} <partialdef>` and :ttag:`{% partial %} <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 %}<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 <TEMPLATES-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": (
+ "<main>{% block main %}Default main content.{% endblock main %}</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&amp",
+ "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>Main content with Inside Content</main>", 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(
+ "<main>Default main content. Main content with Inside Content</main>",
+ 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": (
+ "<div>Before partial</div>"
+ "{% partialdef unclosed_partial %}"
+ "<p>This partial has no closing tag</p>"
+ "<div>After partial content</div>"
+ )
+ }
+ )
+ 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": (
+ "<h1>Title</h1>\n"
+ "{% partialdef testing-name %}\n"
+ "<p>{{ nonexistent|default:alsonotthere }}</p>\n"
+ "{% endpartialdef %}\n"
+ "<h2>Sub Title</h2>\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": (
+ "<h1>Title</h1>\n"
+ "{% partialdef testing-name %}\n"
+ "<p>Content</p>\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": (
+ "<h1>Header</h1>\n"
+ "{% partial undefined %}\n"
+ "<p>After undefined partial</p>\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": (
+ "<h1>Header</h1><p>This template has no partials defined</p>"
+ ),
+ }
+ )
+ 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": (
+ "<h1>Title</h1>\n"
+ "{% partialdef syntax_error_partial %}\n"
+ " {% if user %}\n"
+ " <p>User: {{ user.name }}</p>\n"
+ " {% endif\n"
+ " <p>Missing closing tag above</p>\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": (
+ "<h1>Title</h1>\n"
+ "{% load bad_tag %}\n"
+ "{% partialdef runtime_error_partial %}\n"
+ " <p>This will raise an error:</p>\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": (
+ "<h1>Title</h1>\n"
+ "{% partialdef outer_partial %}\n"
+ ' <div class="outer">\n'
+ " {% partialdef inner_partial %}\n"
+ " <p>{{ undefined_var }}</p>\n"
+ " {% endpartialdef %}\n"
+ " {% partial inner_partial %}\n"
+ " </div>\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("<p>", output)
+ self.assertIn("</p>", output)
+
+ @setup(
+ {
+ "parent.html": (
+ "<!DOCTYPE html>\n"
+ "<html>\n"
+ "<head>{% block title %}Default Title{% endblock %}</head>\n"
+ "<body>\n"
+ " {% block content %}{% endblock %}\n"
+ "</body>\n"
+ "</html>\n"
+ ),
+ "child.html": (
+ "{% extends 'parent.html' %}\n"
+ "{% block content %}\n"
+ " {% partialdef content_partial %}\n"
+ " <p>{{ missing_variable|undefined_filter }}</p>\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": (
+ "<div>Before partial</div>\n"
+ "{% partialdef outer %}\n"
+ "{% partialdef inner %}...{% endpartialdef outer %}\n"
+ "{% endpartialdef inner %}\n"
+ "<div>After partial content</div>"
+ )
+ }
+ )
+ 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": (
+ "<div>Before partial</div>\n"
+ "{% partialdef outer %}\n"
+ "{% partialdef inner %}...{% endpartialdef %}\n"
+ "{% endpartialdef inner %}\n"
+ "<div>After partial content</div>"
+ )
+ }
+ )
+ 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"]