Skip to content

Commit 5e06b97

Browse files
FarhanAliRazacarltongibsonnessitangnpope
committed
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>
1 parent fda3c17 commit 5e06b97

16 files changed

Lines changed: 1587 additions & 2 deletions

File tree

django/template/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
Node,
6262
NodeList,
6363
Origin,
64+
PartialTemplate,
6465
Template,
6566
Variable,
6667
)

django/template/base.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@
8888
# than instantiating SimpleLazyObject with _lazy_re_compile().
8989
tag_re = re.compile(r"({%.*?%}|{{.*?}}|{#.*?#})")
9090

91+
combined_partial_re = re.compile(
92+
r"{%\s*partialdef\s+(?P<name>[\w-]+)(?:\s+inline)?\s*%}"
93+
r"|{%\s*endpartialdef(?:\s+[\w-]+)?\s*%}"
94+
)
95+
9196
logger = logging.getLogger("django.template")
9297

9398

@@ -288,6 +293,57 @@ def get_exception_info(self, exception, token):
288293
}
289294

290295

296+
class PartialTemplate:
297+
"""
298+
A lightweight Template lookalike used for template partials.
299+
300+
Wraps nodelist as a partial, in order to be able to bind context.
301+
"""
302+
303+
def __init__(self, nodelist, origin, name):
304+
self.nodelist = nodelist
305+
self.origin = origin
306+
self.name = name
307+
308+
def get_exception_info(self, exception, token):
309+
template = self.origin.loader.get_template(self.origin.template_name)
310+
return template.get_exception_info(exception, token)
311+
312+
def find_partial_source(self, full_source, partial_name):
313+
start_match = None
314+
nesting = 0
315+
316+
for match in combined_partial_re.finditer(full_source):
317+
if name := match["name"]: # Opening tag.
318+
if start_match is None and name == partial_name:
319+
start_match = match
320+
if start_match is not None:
321+
nesting += 1
322+
elif start_match is not None:
323+
nesting -= 1
324+
if nesting == 0:
325+
return full_source[start_match.start() : match.end()]
326+
327+
return ""
328+
329+
@property
330+
def source(self):
331+
template = self.origin.loader.get_template(self.origin.template_name)
332+
return self.find_partial_source(template.source, self.name)
333+
334+
def _render(self, context):
335+
return self.nodelist.render(context)
336+
337+
def render(self, context):
338+
with context.render_context.push_state(self):
339+
if context.template is None:
340+
with context.bind_template(self):
341+
context.template_name = self.name
342+
return self._render(context)
343+
else:
344+
return self._render(context)
345+
346+
291347
def linebreak_iter(template_source):
292348
yield 0
293349
p = template_source.find("\n")

django/template/defaulttags.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django.conf import settings
1313
from django.http import QueryDict
1414
from django.utils import timezone
15+
from django.utils.datastructures import DeferredSubDict
1516
from django.utils.html import conditional_escape, escape, format_html
1617
from django.utils.lorem_ipsum import paragraphs, words
1718
from django.utils.safestring import mark_safe
@@ -29,6 +30,7 @@
2930
VARIABLE_TAG_START,
3031
Node,
3132
NodeList,
33+
PartialTemplate,
3234
TemplateSyntaxError,
3335
VariableDoesNotExist,
3436
kwarg_re,
@@ -408,6 +410,31 @@ def render(self, context):
408410
return formatted
409411

410412

413+
class PartialDefNode(Node):
414+
def __init__(self, partial_name, inline, nodelist):
415+
self.partial_name = partial_name
416+
self.inline = inline
417+
self.nodelist = nodelist
418+
419+
def render(self, context):
420+
return self.nodelist.render(context) if self.inline else ""
421+
422+
423+
class PartialNode(Node):
424+
def __init__(self, partial_name, partial_mapping):
425+
# Defer lookup in `partial_mapping` and nodelist to runtime.
426+
self.partial_name = partial_name
427+
self.partial_mapping = partial_mapping
428+
429+
def render(self, context):
430+
try:
431+
return self.partial_mapping[self.partial_name].render(context)
432+
except KeyError:
433+
raise TemplateSyntaxError(
434+
f"Partial '{self.partial_name}' is not defined in the current template."
435+
)
436+
437+
411438
class ResetCycleNode(Node):
412439
def __init__(self, node):
413440
self.node = node
@@ -1174,6 +1201,75 @@ def now(parser, token):
11741201
return NowNode(format_string, asvar)
11751202

11761203

1204+
@register.tag(name="partialdef")
1205+
def partialdef_func(parser, token):
1206+
"""
1207+
Declare a partial that can be used in the template.
1208+
1209+
Usage::
1210+
1211+
{% partialdef partial_name %}
1212+
Content goes here.
1213+
{% endpartialdef %}
1214+
1215+
Store the nodelist in the context under the key "partials". It can be
1216+
retrieved using the ``{% partial %}`` tag.
1217+
1218+
The optional ``inline`` argument renders the partial's contents
1219+
immediately, at the point where it is defined.
1220+
"""
1221+
match token.split_contents():
1222+
case "partialdef", partial_name, "inline":
1223+
inline = True
1224+
case "partialdef", partial_name, _:
1225+
raise TemplateSyntaxError(
1226+
"The 'inline' argument does not have any parameters; either use "
1227+
"'inline' or remove it completely."
1228+
)
1229+
case "partialdef", partial_name:
1230+
inline = False
1231+
case ["partialdef"]:
1232+
raise TemplateSyntaxError("'partialdef' tag requires a name")
1233+
case _:
1234+
raise TemplateSyntaxError("'partialdef' tag takes at most 2 arguments")
1235+
1236+
# Parse the content until the end tag.
1237+
valid_endpartials = ("endpartialdef", f"endpartialdef {partial_name}")
1238+
nodelist = parser.parse(valid_endpartials)
1239+
endpartial = parser.next_token()
1240+
if endpartial.contents not in valid_endpartials:
1241+
parser.invalid_block_tag(endpartial, "endpartialdef", valid_endpartials)
1242+
1243+
# Store the partial nodelist in the parser.extra_data attribute.
1244+
partials = parser.extra_data.setdefault("partials", {})
1245+
if partial_name in partials:
1246+
raise TemplateSyntaxError(
1247+
f"Partial '{partial_name}' is already defined in the "
1248+
f"'{parser.origin.name}' template."
1249+
)
1250+
partials[partial_name] = PartialTemplate(nodelist, parser.origin, partial_name)
1251+
1252+
return PartialDefNode(partial_name, inline, nodelist)
1253+
1254+
1255+
@register.tag(name="partial")
1256+
def partial_func(parser, token):
1257+
"""
1258+
Render a partial previously declared with the ``{% partialdef %}`` tag.
1259+
1260+
Usage::
1261+
1262+
{% partial partial_name %}
1263+
"""
1264+
match token.split_contents():
1265+
case "partial", partial_name:
1266+
extra_data = parser.extra_data
1267+
partial_mapping = DeferredSubDict(extra_data, "partials")
1268+
return PartialNode(partial_name, partial_mapping=partial_mapping)
1269+
case _:
1270+
raise TemplateSyntaxError("'partial' tag requires a single argument")
1271+
1272+
11771273
@register.simple_tag(name="querystring", takes_context=True)
11781274
def querystring(context, *args, **kwargs):
11791275
"""

django/template/engine.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,31 @@ def get_template(self, template_name):
174174
Return a compiled Template object for the given template name,
175175
handling template inheritance recursively.
176176
"""
177+
original_name = template_name
178+
try:
179+
template_name, _, partial_name = template_name.partition("#")
180+
except AttributeError:
181+
raise TemplateDoesNotExist(original_name)
182+
183+
if not template_name:
184+
raise TemplateDoesNotExist(original_name)
185+
177186
template, origin = self.find_template(template_name)
178187
if not hasattr(template, "render"):
179188
# template needs to be compiled
180189
template = Template(template, origin, template_name, engine=self)
181-
return template
190+
191+
if not partial_name:
192+
return template
193+
194+
extra_data = getattr(template, "extra_data", {})
195+
try:
196+
partial = extra_data["partials"][partial_name]
197+
except (KeyError, TypeError):
198+
raise TemplateDoesNotExist(partial_name, tried=[template_name])
199+
partial.engine = self
200+
201+
return partial
182202

183203
def render_to_string(self, template_name, context=None):
184204
"""

django/test/utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from django.core.signals import request_started, setting_changed
2525
from django.db import DEFAULT_DB_ALIAS, connections, reset_queries
2626
from django.db.models.options import Options
27-
from django.template import Template
27+
from django.template import PartialTemplate, Template
2828
from django.test.signals import template_rendered
2929
from django.urls import get_script_prefix, set_script_prefix
3030
from django.utils.translation import deactivate
@@ -147,7 +147,9 @@ def setup_test_environment(debug=None):
147147
settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
148148

149149
saved_data.template_render = Template._render
150+
saved_data.partial_template_render = PartialTemplate._render
150151
Template._render = instrumented_test_render
152+
PartialTemplate._render = instrumented_test_render
151153

152154
mail.outbox = []
153155

@@ -165,6 +167,7 @@ def teardown_test_environment():
165167
settings.DEBUG = saved_data.debug
166168
settings.EMAIL_BACKEND = saved_data.email_backend
167169
Template._render = saved_data.template_render
170+
PartialTemplate._render = saved_data.partial_template_render
168171

169172
del _TestState.saved_data
170173
del mail.outbox

django/utils/datastructures.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,21 @@ def _unpack_items(data):
345345
"Element key %r invalid, only strings are allowed" % elem[0]
346346
)
347347
yield elem
348+
349+
350+
class DeferredSubDict:
351+
"""
352+
Wrap a dict, allowing deferred access to a sub-dict under a given key.
353+
354+
The value at ``deferred_key`` must itself be a dict. Accessing
355+
``DeferredSubDict(parent_dict, deferred_key)[key]`` retrieves
356+
``parent_dict[deferred_key][key]`` at access time, so updates to
357+
the parent dict are reflected.
358+
"""
359+
360+
def __init__(self, parent_dict, deferred_key):
361+
self.parent_dict = parent_dict
362+
self.deferred_key = deferred_key
363+
364+
def __getitem__(self, key):
365+
return self.parent_dict[self.deferred_key][key]

docs/ref/templates/builtins.txt

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,80 @@ output (as a string) inside a variable. This is useful if you want to use
957957
{% now "Y" as current_year %}
958958
{% blocktranslate %}Copyright {{ current_year }}{% endblocktranslate %}
959959

960+
.. templatetag:: partial
961+
962+
``partial``
963+
-----------
964+
965+
.. versionadded:: 6.0
966+
967+
Renders a template fragment that was defined with :ttag:`partialdef`, inserting
968+
the matching partial at this location.
969+
970+
Usage:
971+
972+
.. code-block:: html+django
973+
974+
{% partial partial_name %}
975+
976+
The ``partial_name`` argument is the name of the template fragment to render.
977+
978+
In the following example, a partial named ``button`` is defined and then
979+
rendered three times:
980+
981+
.. code-block:: html+django
982+
983+
{% partialdef button %}
984+
<button>Submit</button>
985+
{% endpartialdef %}
986+
987+
{% partial button %}
988+
{% partial button %}
989+
{% partial button %}
990+
991+
.. templatetag:: partialdef
992+
993+
``partialdef``
994+
--------------
995+
996+
.. versionadded:: 6.0
997+
998+
Defines a reusable template fragment that can be rendered multiple times within
999+
the same template or accessed directly via :ref:`template loading or inclusion
1000+
<template-partials-direct-access>`.
1001+
1002+
Usage:
1003+
1004+
.. code-block:: html+django
1005+
1006+
{% partialdef partial_name %}
1007+
{# Reusable content. #}
1008+
{% endpartialdef %}
1009+
1010+
The ``partial_name`` argument is required and must be a valid template
1011+
identifier.
1012+
1013+
In the following example, a new fragment named ``card`` is defined:
1014+
1015+
.. code-block:: html+django
1016+
1017+
{% partialdef card %}
1018+
<div class="card">
1019+
<h3>{{ title }}</h3>
1020+
<p>{{ content }}</p>
1021+
</div>
1022+
{% endpartialdef %}
1023+
1024+
This partial can then be rendered using the :ttag:`partial` tag:
1025+
1026+
.. code-block:: html+django
1027+
1028+
{% partial card %}
1029+
{% partial card %}
1030+
1031+
To :ref:`render a fragment immediately in place <template-partials-inline>`,
1032+
use the ``inline`` option. The partial is still stored and can be reused later.
1033+
9601034
.. templatetag:: querystring
9611035

9621036
``querystring``

0 commit comments

Comments
 (0)