Skip to content

Commit 0c95342

Browse files
Fixed #36410 -- Added named template partials to DTL
- Updated get_template in django backend to load partial template - Added partialdef and partial template tags - Added documentation for the partials tags - Added tests for the partials tags Co-authored-by: Carlton Gibson <carlton@noumenal.es>
1 parent ac2d907 commit 0c95342

File tree

13 files changed

+798
-1
lines changed

13 files changed

+798
-1
lines changed

django/template/backends/django.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,26 @@ def from_string(self, template_code):
7575
return Template(self.engine.from_string(template_code), self)
7676

7777
def get_template(self, template_name):
78+
template_name, _, partial_name = template_name.partition("#")
79+
7880
try:
79-
return Template(self.engine.get_template(template_name), self)
81+
template = self.engine.get_template(template_name)
8082
except TemplateDoesNotExist as exc:
8183
reraise(exc, self)
84+
if not partial_name:
85+
return Template(template, self)
86+
87+
extra_data = getattr(template, "extra_data")
88+
partial_contents = extra_data.get("template-partials", {})
89+
90+
try:
91+
partial = partial_contents[partial_name]
92+
except KeyError:
93+
# Partial not found on this template.
94+
raise TemplateDoesNotExist(partial_name, tried=[template_name])
95+
partial.engine = self.engine
96+
97+
return Template(partial, self)
8298

8399
def get_templatetag_libraries(self, custom_libraries):
84100
"""

django/template/defaulttags.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
from .library import Library
4141
from .smartif import IfParser, Literal
4242

43+
partial_start_tag_re = re.compile(r"\{%\s*(partialdef)\s+([\w-]+)(\s+inline)?\s*%}")
44+
partial_end_tag_re = re.compile(r"\{%\s*endpartialdef\s*%}")
45+
4346
register = Library()
4447

4548

@@ -1564,3 +1567,186 @@ def do_with(parser, token):
15641567
nodelist = parser.parse(("endwith",))
15651568
parser.delete_first_token()
15661569
return WithNode(None, None, nodelist, extra_context=extra_context)
1570+
1571+
1572+
class TemplateProxy:
1573+
"""
1574+
A lightweight Template lookalike used for template partials.
1575+
1576+
Wraps nodelist as partial, in order to bind context.
1577+
"""
1578+
1579+
def __init__(self, nodelist, origin, name):
1580+
self.nodelist = nodelist
1581+
self.origin = origin
1582+
self.name = name
1583+
1584+
def get_exception_info(self, exception, token):
1585+
template = self.origin.loader.get_template(self.origin.template_name)
1586+
return template.get_exception_info(exception, token)
1587+
1588+
def find_partial_source(self, full_source, partial_name):
1589+
result = ""
1590+
pos = 0
1591+
for m in partial_start_tag_re.finditer(full_source, pos):
1592+
sspos, sepos = m.span()
1593+
starter, name, inline = m.groups()
1594+
endm = partial_end_tag_re.search(full_source, sepos + 1)
1595+
assert endm, "End tag must be present"
1596+
espos, eepos = endm.span()
1597+
if name == partial_name:
1598+
# Include the full partial definition from opening to closing tag
1599+
result = full_source[sspos:eepos]
1600+
break
1601+
pos = eepos + 1
1602+
return result
1603+
1604+
@property
1605+
def source(self):
1606+
template = self.origin.loader.get_template(self.origin.template_name)
1607+
return self.find_partial_source(template.source, self.name)
1608+
1609+
def _render(self, context):
1610+
return self.nodelist.render(context)
1611+
1612+
def render(self, context):
1613+
"Display stage -- can be called many times"
1614+
with context.render_context.push_state(self):
1615+
if context.template is None:
1616+
with context.bind_template(self):
1617+
context.template_name = self.name
1618+
return self._render(context)
1619+
else:
1620+
return self._render(context)
1621+
1622+
1623+
class DefinePartialNode(Node):
1624+
def __init__(self, partial_name, inline, nodelist):
1625+
self.partial_name = partial_name
1626+
self.inline = inline
1627+
self.nodelist = nodelist
1628+
1629+
def render(self, context):
1630+
"""Set content into context and return empty string"""
1631+
if self.inline:
1632+
return self.nodelist.render(context)
1633+
else:
1634+
return ""
1635+
1636+
1637+
class RenderPartialNode(Node):
1638+
def __init__(self, partial_name, partial_mapping):
1639+
# Defer lookup of nodelist to runtime.
1640+
self.partial_name = partial_name
1641+
self.partial_mapping = partial_mapping
1642+
1643+
def render(self, context):
1644+
return self.partial_mapping[self.partial_name].render(context)
1645+
1646+
1647+
@register.tag(name="partialdef")
1648+
def partialdef_func(parser, token):
1649+
"""
1650+
Declare a partial that can be used later in the template.
1651+
1652+
Usage::
1653+
1654+
{% partialdef partial_name %}
1655+
Partial content goes here
1656+
{% endpartialdef %}
1657+
1658+
Stores the nodelist in the context under the key "partial_contents" and can
1659+
be retrieved using the {% partial %} tag.
1660+
1661+
The optional ``inline`` argument will render the contents of the partial
1662+
where it is defined.
1663+
"""
1664+
# Parse the tag
1665+
tokens = token.split_contents()
1666+
1667+
# check we have the expected number of tokens before trying to assign them
1668+
# via indexes
1669+
if len(tokens) not in (2, 3):
1670+
raise TemplateSyntaxError(
1671+
"%r tag requires 2-3 arguments" % token.contents.split()[0]
1672+
)
1673+
1674+
partial_name = tokens[1]
1675+
1676+
try:
1677+
inline = tokens[2]
1678+
except IndexError:
1679+
# the inline argument is optional, so fallback to not using it
1680+
inline = False
1681+
1682+
if inline and inline != "inline":
1683+
warnings.warn(
1684+
"The 'inline' argument does not have any parameters; "
1685+
"either use 'inline' or remove it completely.",
1686+
DeprecationWarning,
1687+
)
1688+
1689+
# Parse the content until the end tag
1690+
acceptable_endpartials = ("endpartialdef", f"endpartialdef {partial_name}")
1691+
nodelist = parser.parse(acceptable_endpartials)
1692+
endpartial = parser.next_token()
1693+
if endpartial.contents not in acceptable_endpartials:
1694+
parser.invalid_block_tag(endpartial, "endpartialdef", acceptable_endpartials)
1695+
1696+
# Store the partial nodelist in the parser.extra_data attribute,
1697+
parser.extra_data.setdefault("template-partials", {})
1698+
parser.extra_data["template-partials"][partial_name] = TemplateProxy(
1699+
nodelist, parser.origin, partial_name
1700+
)
1701+
1702+
return DefinePartialNode(partial_name, inline, nodelist)
1703+
1704+
1705+
class SubDictionaryWrapper:
1706+
"""
1707+
Wrap a parent dictionary, allowing deferred access to a sub-dictionary by key.
1708+
The parser.extra_data storage may not yet be populated when a partial node
1709+
is defined, so defer access until rendering.
1710+
"""
1711+
1712+
def __init__(self, parent_dict, lookup_key):
1713+
self.parent_dict = parent_dict
1714+
self.lookup_key = lookup_key
1715+
1716+
def __getitem__(self, key):
1717+
try:
1718+
partials_content = self.parent_dict[self.lookup_key]
1719+
except KeyError:
1720+
raise TemplateSyntaxError(
1721+
f"No partials are defined. You are trying to access '{key}' partial"
1722+
)
1723+
1724+
try:
1725+
return partials_content[key]
1726+
except KeyError:
1727+
raise TemplateSyntaxError(
1728+
f"You are trying to access an undefined partial '{key}'"
1729+
)
1730+
1731+
1732+
# Define the partial tag to render the partial content.
1733+
@register.tag(name="partial")
1734+
def partial_func(parser, token):
1735+
"""
1736+
Render a partial that was previously declared using
1737+
the {% partialdef %} tag.
1738+
1739+
Usage::
1740+
1741+
{% partial partial_name %}
1742+
"""
1743+
# Parse the tag
1744+
try:
1745+
tag_name, partial_name = token.split_contents()
1746+
except ValueError:
1747+
raise TemplateSyntaxError("%r tag requires a single argument" % tag_name)
1748+
1749+
extra_data = getattr(parser, "extra_data")
1750+
partial_mapping = SubDictionaryWrapper(extra_data, "template-partials")
1751+
1752+
return RenderPartialNode(partial_name, partial_mapping=partial_mapping)

django/test/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from django.db import DEFAULT_DB_ALIAS, connections, reset_queries
2626
from django.db.models.options import Options
2727
from django.template import Template
28+
from django.template.defaulttags import TemplateProxy
2829
from django.test.signals import template_rendered
2930
from django.urls import get_script_prefix, set_script_prefix
3031
from django.utils.translation import deactivate
@@ -147,7 +148,9 @@ def setup_test_environment(debug=None):
147148
settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
148149

149150
saved_data.template_render = Template._render
151+
saved_data.template_proxy_render = TemplateProxy._render
150152
Template._render = instrumented_test_render
153+
TemplateProxy._render = instrumented_test_render
151154

152155
mail.outbox = []
153156

@@ -165,6 +168,7 @@ def teardown_test_environment():
165168
settings.DEBUG = saved_data.debug
166169
settings.EMAIL_BACKEND = saved_data.email_backend
167170
Template._render = saved_data.template_render
171+
TemplateProxy._render = saved_data.template_proxy_render
168172

169173
del _TestState.saved_data
170174
del mail.outbox

docs/ref/templates/api.txt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,29 @@ overridden by what's passed by
158158
Loads a template with the given name, compiles it and returns a
159159
:class:`Template` object.
160160

161+
**Partial loading:**
162+
163+
To load a specific partial from a template:
164+
165+
.. code-block:: python
166+
167+
# Load entire template
168+
template = engine.get_template("template.html")
169+
170+
# Load specific partial from template
171+
partial = engine.get_template("template.html#partial_name")
172+
173+
See :ref:`template-partials` for more information about defining and using
174+
template partials.
175+
176+
When loading partials, the method returns a template object that behaves
177+
like a regular :class:`Template` but contains only the partial's content.
178+
179+
.. versionchanged:: 6.0
180+
181+
Added support for loading template partials using the
182+
``template_name#partial_name`` syntax.
183+
161184
.. method:: Engine.select_template(template_name_list)
162185

163186
Like :meth:`~Engine.get_template`, except it takes a list of names

docs/ref/templates/builtins.txt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,83 @@ 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 partial that was defined with :ttag:`partialdef`.
968+
969+
Usage:
970+
971+
.. code-block:: html+django
972+
973+
{% partial partial_name %}
974+
975+
The ``partial_name`` argument is the string identifier of the partial to render.
976+
977+
Example:
978+
979+
.. code-block:: html+django
980+
981+
{% partialdef button %}
982+
<button class="btn">{{ button_text }}</button>
983+
{% endpartialdef %}
984+
985+
{% partial button %}
986+
{% partial button %}
987+
988+
989+
.. templatetag:: partialdef
990+
991+
``partialdef``
992+
--------------
993+
994+
.. versionadded:: 6.0
995+
996+
Defines a reusable template partial that can be rendered multiple times within
997+
the same template or accessed directly via template loading.
998+
999+
Usage:
1000+
1001+
.. code-block:: html+django
1002+
1003+
{% partialdef partial_name %}
1004+
<!-- partial content -->
1005+
{% endpartialdef %}
1006+
1007+
The ``{% partialdef %}`` tag can be used with one or two arguments.
1008+
The arguments are:
1009+
1010+
================ =============================================================
1011+
Argument Description
1012+
================ =============================================================
1013+
``partial_name`` String identifier for the partial (required).
1014+
``inline`` The word ``inline``, which if given, renders the partial
1015+
content immediately in addition to defining it.
1016+
================ =============================================================
1017+
1018+
Examples:
1019+
1020+
* ``{% partialdef card %}...{% endpartialdef %}`` defines a partial named "card".
1021+
* ``{% partialdef header inline %}...{% endpartialdef %}`` defines and immediately
1022+
renders a partial named "header".
1023+
1024+
.. code-block:: html+django
1025+
1026+
{% partialdef card %}
1027+
<div class="card">
1028+
<h3>{{ title }}</h3>
1029+
<p>{{ content }}</p>
1030+
</div>
1031+
{% endpartialdef %}
1032+
1033+
{% partialdef header inline %}
1034+
<header>Site Header</header>
1035+
{% endpartialdef %}
1036+
9601037
.. templatetag:: querystring
9611038

9621039
``querystring``

0 commit comments

Comments
 (0)