Latest Threat Research:SANDWORM_MODE: Shai-Hulud-Style npm Worm Hijacks CI Workflows and Poisons AI Toolchains.Details
Socket
Book a DemoSign in
Socket

laces

Package Overview
Dependencies
Maintainers
2
Versions
3
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

laces - pypi Package Compare versions

Comparing version
0.1.0
to
0.1.1
laces/templates/.gitkeep

Sorry, the diff of this file is not supported yet

+284
import os
import random
from pathlib import Path
from typing import TYPE_CHECKING
from django.conf import settings
from django.forms import widgets
from django.template import Context
from django.test import SimpleTestCase
from django.utils.safestring import SafeString
from laces.components import Component, MediaContainer
from laces.tests.utils import MediaAssertionMixin
if TYPE_CHECKING:
from typing import Any, Optional, Union
from laces.typing import RenderContext
class TestComponent(MediaAssertionMixin, SimpleTestCase):
"""Directly test the Component class."""
def setUp(self) -> None:
self.component = Component()
def test_render_html(self) -> None:
"""Test the `render_html` method."""
# The default Component does not specify a `template_name` attribute which is
# required for `render_html`. So calling the method on the Component class
# will raise an error.
with self.assertRaises(AttributeError):
self.component.render_html()
def test_get_context_data_parent_context_empty_context(self) -> None:
"""
Test the default get_context_data.
The parent context should not matter, but we use it as it is used in
`render_html` (which passes a `Context` object).
"""
result = self.component.get_context_data(parent_context=Context())
self.assertIsInstance(result, dict)
self.assertEqual(result, {})
def test_media(self) -> None:
"""
Test the `media` property.
The `media` property is added through the `metaclass=MediaDefiningClass`
definition.
"""
empty_media = widgets.Media()
self.assertIsInstance(self.component.media, widgets.Media)
self.assertMediaEqual(self.component.media, empty_media)
class TestComponentSubclasses(MediaAssertionMixin, SimpleTestCase):
"""
Test the Component class through subclasses.
Most functionality of the Component class is only unlocked through subclassing and
definition of certain attributes (like `template_name`) or overriding of the
existing methods. This test class tests the functionality that is unlocked through
subclassing.
"""
@classmethod
def make_example_template_name(cls) -> str:
return f"example-{random.randint(1000, 10000)}.html"
@classmethod
def get_example_template_name(cls) -> str:
example_template_name = cls.make_example_template_name()
while os.path.exists(example_template_name):
example_template_name = cls.make_example_template_name()
return example_template_name
def setUp(self) -> None:
self.example_template_name = self.get_example_template_name()
self.example_template = (
Path(settings.PROJECT_DIR) / "templates" / self.example_template_name
)
# Write content to the template file to ensure it exists.
self.set_example_template_content("")
def set_example_template_content(self, content: str) -> None:
with open(self.example_template, "w") as f:
f.write(content)
def test_render_html_with_template_name_set(self) -> None:
"""
Test `render_html` method with a set `template_name` attribute.
"""
# -----------------------------------------------------------------------------
class ExampleComponent(Component):
template_name = self.example_template_name
# -----------------------------------------------------------------------------
self.set_example_template_content("Test")
result = ExampleComponent().render_html()
self.assertIsInstance(result, str)
self.assertIsInstance(result, SafeString)
self.assertEqual(result, "Test")
def test_render_html_with_template_name_set_and_data_from_get_context_data(
self,
) -> None:
"""
Test `render_html` method with `get_context_data` providing data for the
context.
"""
# -----------------------------------------------------------------------------
class ExampleComponent(Component):
template_name = self.example_template_name
def get_context_data(
self,
parent_context: "Optional[RenderContext]",
) -> "RenderContext":
return {"name": "World"}
# -----------------------------------------------------------------------------
self.set_example_template_content("Hello {{ name }}")
result = ExampleComponent().render_html()
self.assertEqual(result, "Hello World")
def test_render_html_when_get_context_data_returns_None(self) -> None:
"""
Test `render_html` method when `get_context_data` returns `None`.
The `render_html` method raises a `TypeError` when `None` is returned from
`get_context_method`. This behavior was present when the class was extracted
from Wagtail. It is not totally clear why this specific check is needed. By
default, the `get_context_data` method provides and empty dict. If an override
wanted to `get_context_data` return `None`, it should be expected that no
context data is available during rendering. The underlying `template.render`
method does not seem to be ok with `None` as the provided context.
"""
# -----------------------------------------------------------------------------
class ExampleComponent(Component):
def get_context_data(
self,
parent_context: "Optional[Union[Context, dict[str, Any]]]",
) -> None:
return None
# -----------------------------------------------------------------------------
with self.assertRaises(TypeError):
ExampleComponent().render_html()
def test_media_defined_through_nested_class(self) -> None:
"""
Test the `media` property when defined through a nested class.
The `media` property is added through the `metaclass=MediaDefiningClass`
definition. This test ensures that the `media` property is available when
configured through a nested class.
"""
# -----------------------------------------------------------------------------
class ExampleComponent(Component):
class Media:
css = {"all": ["example.css"]}
js = ["example.js"]
# -----------------------------------------------------------------------------
result = ExampleComponent().media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(
result,
widgets.Media(css={"all": ["example.css"]}, js=["example.js"]),
)
def tearDown(self) -> None:
os.remove(path=self.example_template)
class TestMediaContainer(MediaAssertionMixin, SimpleTestCase):
"""
Test the MediaContainer class.
The `MediaContainer` functionality depends on the `django.forms.widgets.Media`
class. The `Media` class provides the logic to combine the media definitions of
multiple objects through its `__add__` method. The `MediaContainer` relies on this
functionality to provide a `media` property that combines the media definitions of
its members.
See also:
https://docs.djangoproject.com/en/4.2/topics/forms/media
"""
def setUp(self) -> None:
self.media_container = MediaContainer()
def test_empty(self) -> None:
result = self.media_container.media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(result, widgets.Media())
def test_single_member(self) -> None:
# -----------------------------------------------------------------------------
class ExampleClass:
media = widgets.Media(css={"all": ["example.css"]})
# -----------------------------------------------------------------------------
example = ExampleClass()
self.media_container.append(example)
result = self.media_container.media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(result, example.media)
self.assertMediaEqual(result, widgets.Media(css={"all": ["example.css"]}))
def test_two_members_of_same_class(self) -> None:
# -----------------------------------------------------------------------------
class ExampleClass:
media = widgets.Media(css={"all": ["example.css"]}, js=["example.js"])
# -----------------------------------------------------------------------------
example_1 = ExampleClass()
example_2 = ExampleClass()
self.media_container.append(example_1)
self.media_container.append(example_2)
result = self.media_container.media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(
result,
widgets.Media(css={"all": ["example.css"]}, js=["example.js"]),
)
def test_two_members_of_different_classes(self) -> None:
# -----------------------------------------------------------------------------
class ExampleClass:
media = widgets.Media(css={"all": ["shared.css"]}, js=["example.js"])
class OtherExampleClass:
media = widgets.Media(
css={
"all": ["other.css", "shared.css"],
"print": ["print.css"],
},
js=["other.js"],
)
# -----------------------------------------------------------------------------
example = ExampleClass()
self.media_container.append(example)
other = OtherExampleClass()
self.media_container.append(other)
result = self.media_container.media
self.assertIsInstance(result, widgets.Media)
self.assertMediaEqual(
result,
widgets.Media(
css={
"all": ["other.css", "shared.css"],
"print": ["print.css"],
},
js=["example.js", "other.js"],
),
)
"""Utilities for tests in the `laces` package."""
from django.forms import widgets
class MediaAssertionMixin:
@staticmethod
def assertMediaEqual(first: widgets.Media, second: widgets.Media) -> bool:
"""
Compare two `Media` instances.
The `Media` class does not implement `__eq__`, but its `__repr__` shows how to
recreate the instance.
We can use this to compare two `Media` instances.
Parameters
----------
first : widgets.Media
First `Media` instance.
second : widgets.Media
Second `Media` instance.
Returns
-------
bool
Whether the two `Media` instances are equal.
"""
return repr(first) == repr(second)
from typing import TYPE_CHECKING, Protocol, Union
if TYPE_CHECKING:
from typing import Any, Optional, TypeAlias
from django.forms.widgets import Media
from django.template import Context
from django.utils.safestring import SafeString
RenderContext: TypeAlias = Union[Context, dict[str, Any]]
class HasRenderHtmlMethod(Protocol):
def render_html( # noqa: E704
self,
parent_context: "Optional[RenderContext]",
) -> "SafeString": ...
class HasRenderMethod(Protocol):
def render( # noqa: E704
self,
) -> "SafeString": ...
Renderable: "TypeAlias" = Union[HasRenderHtmlMethod, HasRenderMethod]
class HasMediaProperty(Protocol):
@property
def media(self) -> "Media": ... # noqa: E704
+29
-4

@@ -1,2 +0,2 @@

# laces Changelog
# Laces Changelog

@@ -8,4 +8,30 @@ All notable changes to this project will be documented in this file.

## [Unreleased]
## [Unreleased] - YYYY-MM-DD
### Added
- ...
### Changed
- ...
### Removed
- ...
## [0.1.1] - 2024-02-10
### Added
- Added more tests and example usage. ([#6](https://github.com/tbrlpld/laces/pull/6))
- Added support for Python 3.12 and Django 5.0. ([#15](https://github.com/tbrlpld/laces/pull/15))
- Added type hints and type checking with `mypy` in CI. ([#18](https://github.com/tbrlpld/laces/pull/18))
### Changed
- Fixed tox configuration to actually run Django 3.2 in CI. Tox also uses the "testing" dependencies without the need to duplicate them in the `tox.ini`. ([#10](https://github.com/tbrlpld/laces/pull/10))
- Bumped GitHub Actions to the latest versions. This removes a reliance on the now deprecated Node 16. ([#10](https://github.com/tbrlpld/laces/pull/10))
- Extend documentation in README to simplify first examples and improve structure. ([#7](https://github.com/tbrlpld/laces/pull/7))
## [0.1.0] - 2023-11-29

@@ -20,5 +46,4 @@

## [Unreleased] - YYYY-MM-DD
## [x.y.z] - YYYY-MM-DD
### Added

@@ -25,0 +50,0 @@

+1
-1

@@ -1,2 +0,2 @@

VERSION = (0, 1, 0)
VERSION = (0, 1, 1)
__version__ = ".".join(map(str, VERSION))

@@ -1,8 +0,18 @@

from typing import Any, MutableMapping
from typing import TYPE_CHECKING, List
from django.forms import Media, MediaDefiningClass
from django.forms.widgets import Media, MediaDefiningClass
from django.template import Context
from django.template.loader import get_template
from laces.typing import HasMediaProperty
if TYPE_CHECKING:
from typing import Optional
from django.utils.safestring import SafeString
from laces.typing import RenderContext
class Component(metaclass=MediaDefiningClass):

@@ -14,10 +24,15 @@ """

https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/ui/components.py#L8-L22 # noqa: E501
A component uses the `MetaDefiningClass` metaclass to add a `media` property, which
allows the definitions of CSS and JavaScript assets that are associated with the
component. This works the same as `Media` class used by Django forms.
See also: https://docs.djangoproject.com/en/4.2/topics/forms/media/
"""
def get_context_data(
self, parent_context: MutableMapping[str, Any]
) -> MutableMapping[str, Any]:
return {}
template_name: str
def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str:
def render_html(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "SafeString":
"""

@@ -27,3 +42,3 @@ Return string representation of the object.

Given a context dictionary from the calling template (which may be a
`django.template.Context` object or a plain ``dict`` of context variables),
`django.template.Context` object or a plain `dict` of context variables),
returns the string representation to be rendered.

@@ -44,6 +59,24 @@

def get_context_data(
self,
parent_context: "RenderContext",
) -> "Optional[RenderContext]":
return {}
class MediaContainer(list):
# fmt: off
if TYPE_CHECKING:
# It's ugly, I know. But it seems to be the best way to make `mypy` happy.
# The `media` property is dynamically added by the `MediaDefiningClass`
# metaclass. Because of how dynamic it is, `mypy` is not able to pick it up.
# This is why we need to add a type hint for it here. The other way would be a
# stub, but that would require the whole module to be stubbed and that is even
# more annoying to keep up to date.
@property
def media(self) -> Media: ... # noqa: E704
# fmt: on
class MediaContainer(List[HasMediaProperty]):
"""
A list that provides a ``media`` property that combines the media definitions
A list that provides a `media` property that combines the media definitions
of its members.

@@ -53,6 +86,22 @@

https://github.com/wagtail/wagtail/blob/ca8a87077b82e20397e5a5b80154d923995e6ca9/wagtail/admin/ui/components.py#L25-L36 # noqa: E501
The `MediaContainer` functionality depends on the `django.forms.widgets.Media`
class. The `Media` class provides the logic to combine the media definitions of
multiple objects through its `__add__` method. The `MediaContainer` relies on this
functionality to provide a `media` property that combines the media definitions of
its members.
See also:
https://docs.djangoproject.com/en/4.2/topics/forms/media
"""
@property
def media(self):
def media(self) -> Media:
"""
Return a `Media` object containing the media definitions of all members.
This makes use of the `Media.__add__` method, which combines the media
definitions of two `Media` objects.
"""
media = Media()

@@ -59,0 +108,0 @@ for item in self:

@@ -0,6 +1,17 @@

from typing import TYPE_CHECKING
from django import template
from django.template.base import token_kwargs
from django.template.defaultfilters import conditional_escape
from django.utils.html import conditional_escape
from django.utils.safestring import SafeString
if TYPE_CHECKING:
from typing import Optional
from django.template.base import FilterExpression, Parser, Token
from laces.typing import Renderable
register = template.library.Library()

@@ -19,8 +30,8 @@

self,
component,
extra_context=None,
isolated_context=False,
fallback_render_method=None,
target_var=None,
):
component: "FilterExpression",
extra_context: "Optional[dict[str, FilterExpression]]" = None,
isolated_context: bool = False,
fallback_render_method: "Optional[FilterExpression]" = None,
target_var: "Optional[str]" = None,
) -> None:
self.component = component

@@ -32,11 +43,27 @@ self.extra_context = extra_context or {}

def render(self, context: template.Context) -> str:
# Render a component by calling its render_html method, passing request and context from the
# calling template.
# If fallback_render_method is true, objects without a render_html method will have render()
# called instead (with no arguments) - this is to provide deprecation path for things that have
# been newly upgraded to use the component pattern.
def render(self, context: template.Context) -> SafeString:
"""
Render the ComponentNode template node.
component = self.component.resolve(context)
The rendering is done by rendering the passed component by calling its
`render_html` method and passing context from the calling template.
If the passed object does not have a `render_html` method but a `render` method
and the `fallback_render_method` arguments of the template tag is true, then
the `render` method is used. The `render` method does not receive any arguments.
Additional context variables can be passed to the component by using the `with`
keyword. The `with` keyword accepts a list of key-value pairs. The key is the
name of the context variable and the value is the value of the context variable.
The `only` keyword can be used to isolate the context. This means that the
context variables from the parent context are not passed to the component the
only context variables passed to the component are the ones passed with the
`with` keyword.
The `as` keyword can be used to store the rendered component in a variable
in the parent context. The variable name is passed after the `as` keyword.
"""
component: "Renderable" = self.component.resolve(context)
if self.fallback_render_method:

@@ -64,3 +91,3 @@ fallback_render_method = self.fallback_render_method.resolve(context)

context[self.target_var] = html
return ""
return SafeString("")
else:

@@ -73,3 +100,3 @@ if context.autoescape:

@register.tag(name="component")
def component(parser, token):
def component(parser: "Parser", token: "Token") -> ComponentNode:
"""

@@ -91,5 +118,5 @@ Template tag to render a component via ComponentNode.

# is fallback_render_method
flags = token_kwargs(bits, parser)
fallback_render_method = flags.pop("fallback_render_method", None)
if flags:
kwargs = token_kwargs(bits, parser)
fallback_render_method = kwargs.pop("fallback_render_method", None)
if kwargs:
raise template.TemplateSyntaxError(

@@ -96,0 +123,0 @@ "'component' tag only accepts 'fallback_render_method' as a keyword argument"

@@ -1,2 +0,11 @@

from django.template import Context, Template
import os
import random
from copy import deepcopy
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from django.conf import settings
from django.template import Context, Template, TemplateSyntaxError
from django.test import SimpleTestCase

@@ -8,2 +17,34 @@ from django.utils.html import format_html

if TYPE_CHECKING:
from typing import Any, Dict, List
from django.utils.safestring import SafeString
from laces.typing import RenderContext
class CopyingMock(MagicMock):
"""
A mock that stores copies of the call arguments.
The default behaviour of a mock is to store references to the call arguments. This
means that if the call arguments are mutable, then the stored call arguments will
change when the call arguments are changed. This is not always desirable. E.g. the
`django.template.Context` class is mutable and the different layers are popped off
the context during rendering. This makes it hard to inspect the context that was
passed to a mock.
This variant of the mock stores copies of the call arguments. This means that the
stored call arguments will not change when the actual call arguments are changed.
This override is based on the Python docs:
https://docs.python.org/3/library/unittest.mock-examples.html#coping-with-mutable-arguments # noqa: E501
"""
def __call__(self, /, *args: "List[Any]", **kwargs: "Dict[str, Any]") -> "Any":
args = deepcopy(args)
kwargs = deepcopy(kwargs)
return super().__call__(*args, **kwargs)
class TestComponentTag(SimpleTestCase):

@@ -17,77 +58,386 @@ """

def test_passing_context_to_component(self):
class MyComponent(Component):
def render_html(self, parent_context):
return format_html(
"<h1>{} was here</h1>", parent_context.get("first_name", "nobody")
)
def setUp(self) -> None:
self.parent_template = Template("")
template = Template(
"{% load laces %}{% with first_name='Kilroy' %}{% component my_component %}{% endwith %}"
class ExampleComponent(Component):
# Using a mock to be able to check if the `render_html` method is called.
render_html = CopyingMock(return_value="Rendered HTML")
self.component = ExampleComponent()
def set_parent_template(self, template_string: str) -> None:
template_string = "{% load laces %}" + template_string
self.parent_template = Template(template_string)
def render_parent_template_with_context(
self,
context: "RenderContext",
) -> "SafeString":
"""
Render the parent template with the given context.
Parameters
----------
context: RenderContext
Context to render the parent template with.
Returns
-------
SafeString
The parent template rendered with the given context.
"""
return self.parent_template.render(Context(context))
def assertVariablesAvailableInRenderHTMLParentContext(
self,
expected_context_variables: "Dict[str, Any]",
) -> None:
"""
Assert that the variables defined in the given dictionary are available in the
parent context of the `render_html` method.
Keys and values are checked.
"""
actual_context = self.component.render_html.call_args.args[0]
for key, value in expected_context_variables.items():
self.assertIn(key, actual_context)
actual_value = actual_context[key]
if not isinstance(actual_value, Component):
# Because we are inspecting copies of the context variables, we cannot
# easily compare the components by identity. For now, we just
# skip components.
self.assertEqual(actual_value, value)
def test_render_html_return_in_parent_template(self) -> None:
self.assertEqual(self.component.render_html(), "Rendered HTML")
self.set_parent_template("Before {% component my_component %} After")
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
html = template.render(Context({"my_component": MyComponent()}))
self.assertEqual(html, "<h1>Kilroy was here</h1>")
template = Template(
"{% load laces %}{% component my_component with first_name='Kilroy' %}"
# This matches the return value of the `render_html` method inserted into the
# parent template.
self.assertEqual(result, "Before Rendered HTML After")
def test_render_html_return_is_escaped(self) -> None:
self.component.render_html.return_value = (
"Look, I'm running with scissors! 8< 8< 8<"
)
html = template.render(Context({"my_component": MyComponent()}))
self.assertEqual(html, "<h1>Kilroy was here</h1>")
self.set_parent_template("{% component my_component %}")
template = Template(
"{% load laces %}{% with first_name='Kilroy' %}{% component my_component with surname='Silk' only %}{% endwith %}"
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
html = template.render(Context({"my_component": MyComponent()}))
self.assertEqual(html, "<h1>nobody was here</h1>")
def test_fallback_render_method(self):
class MyComponent(Component):
def render_html(self, parent_context):
return format_html("<h1>I am a component</h1>")
self.assertEqual(
result,
"Look, I&#x27;m running with scissors! 8&lt; 8&lt; 8&lt;",
)
class MyNonComponent:
def render(self):
return format_html("<h1>I am not a component</h1>")
def test_render_html_return_not_escaped_when_formatted_html(self) -> None:
self.component.render_html.return_value = format_html("<h1>My component</h1>")
self.set_parent_template("{% component my_component %}")
template = Template("{% load laces %}{% component my_component %}")
html = template.render(Context({"my_component": MyComponent()}))
self.assertEqual(html, "<h1>I am a component</h1>")
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
self.assertEqual(result, "<h1>My component</h1>")
def test_render_html_return_not_escaped_when_actually_rendered_template(
self,
) -> None:
example_template_name = f"example-{random.randint(1000, 10000)}.html"
example_template = (
Path(settings.PROJECT_DIR) / "templates" / example_template_name
)
with open(example_template, "w") as f:
f.write("<h1>My component</h1>")
# -----------------------------------------------------------------------------
class RealExampleComponent(Component):
template_name = example_template_name
# -----------------------------------------------------------------------------
component = RealExampleComponent()
self.set_parent_template("{% component my_component %}")
result = self.render_parent_template_with_context(
{"my_component": component},
)
self.assertEqual(result, "<h1>My component</h1>")
os.remove(example_template)
def test_render_html_parent_context_when_only_component_in_context(self) -> None:
self.set_parent_template("{% component my_component %}")
self.render_parent_template_with_context({"my_component": self.component})
self.assertVariablesAvailableInRenderHTMLParentContext(
{"my_component": self.component}
)
def test_render_html_parent_context_when_other_variable_in_context(self) -> None:
self.set_parent_template("{% component my_component %}")
self.render_parent_template_with_context(
{
"my_component": self.component,
"test": "something",
}
)
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
"test": "something",
}
)
def test_render_html_parent_context_when_with_block_sets_extra_context(
self,
) -> None:
self.set_parent_template(
"{% with test='something' %}{% component my_component %}{% endwith %}"
)
self.render_parent_template_with_context({"my_component": self.component})
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
"test": "something",
}
)
def test_render_html_parent_context_when_with_keyword_sets_extra_context(
self,
) -> None:
self.set_parent_template("{% component my_component with test='something' %}")
self.render_parent_template_with_context({"my_component": self.component})
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
"test": "something",
}
)
def test_render_html_parent_context_when_with_only_keyword_limits_extra_context(
self,
) -> None:
self.set_parent_template(
"{% component my_component with test='nothing else' only %}"
)
self.render_parent_template_with_context(
{
"my_component": self.component,
"other": "something else",
}
)
# The `my_component` and `other` variables from the parent's rendering context
# are not included in the context that is passed to the `render_html` method.
# The `test` variable, that was defined with the with-keyword, is present
# though. Both of these effects come form the `only` keyword.
self.assertVariablesAvailableInRenderHTMLParentContext({"test": "nothing else"})
def test_render_html_parent_context_when_with_block_overrides_context(self) -> None:
self.set_parent_template(
"{% with test='something else' %}{% component my_component %}{% endwith %}"
)
self.render_parent_template_with_context(
{
"my_component": self.component,
"test": "something",
}
)
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
# The `test` variable is overriden by the `with` block.
"test": "something else",
}
)
def test_render_html_parent_context_when_with_keyword_overrides_context(
self,
) -> None:
self.set_parent_template(
"{% component my_component with test='something else' %}"
)
self.render_parent_template_with_context(
{
"my_component": self.component,
"test": "something",
}
)
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
# The `test` variable is overriden by the `with` keyword.
"test": "something else",
},
)
def test_render_html_parent_context_when_with_keyword_overrides_with_block(
self,
) -> None:
self.set_parent_template(
"""
{% with test='something' %}
{% component my_component with test='something else' %}
{% endwith %}
"""
)
self.render_parent_template_with_context({"my_component": self.component})
self.assertVariablesAvailableInRenderHTMLParentContext(
{
"my_component": self.component,
"test": "something else",
}
)
def test_fallback_render_method_arg_true_and_object_with_render_method(
self,
) -> None:
# -----------------------------------------------------------------------------
class ExampleNonComponentWithRenderMethod:
def render(self) -> str:
return "Rendered non-component"
# -----------------------------------------------------------------------------
non_component = ExampleNonComponentWithRenderMethod()
self.set_parent_template(
"{% component my_non_component fallback_render_method=True %}"
)
result = self.render_parent_template_with_context(
{"my_non_component": non_component},
)
self.assertEqual(result, "Rendered non-component")
def test_fallback_render_method_arg_true_but_object_without_render_method(
self,
) -> None:
# -----------------------------------------------------------------------------
class ExampleNonComponentWithoutRenderMethod:
pass
# -----------------------------------------------------------------------------
non_component = ExampleNonComponentWithoutRenderMethod()
self.set_parent_template(
"{% component my_non_component fallback_render_method=True %}"
)
with self.assertRaises(ValueError):
template.render(Context({"my_component": MyNonComponent()}))
self.render_parent_template_with_context(
{"my_non_component": non_component},
)
template = Template(
"{% load laces %}{% component my_component fallback_render_method=True %}"
def test_no_fallback_render_method_arg_and_object_without_render_method(
self,
) -> None:
# -----------------------------------------------------------------------------
class ExampleNonComponentWithoutRenderMethod:
def __repr__(self) -> str:
return "<Example repr>"
# -----------------------------------------------------------------------------
non_component = ExampleNonComponentWithoutRenderMethod()
self.set_parent_template("{% component my_non_component %}")
with self.assertRaises(ValueError) as cm:
self.render_parent_template_with_context(
{"my_non_component": non_component},
)
self.assertEqual(
str(cm.exception),
"Cannot render <Example repr> as a component",
)
html = template.render(Context({"my_component": MyComponent()}))
self.assertEqual(html, "<h1>I am a component</h1>")
html = template.render(Context({"my_component": MyNonComponent()}))
self.assertEqual(html, "<h1>I am not a component</h1>")
def test_component_escapes_unsafe_strings(self):
class MyComponent(Component):
def render_html(self, parent_context):
return "Look, I'm running with scissors! 8< 8< 8<"
def test_as_keyword_stores_render_html_return_as_variable(self) -> None:
self.set_parent_template(
"{% component my_component as my_var %}The result was: {{ my_var }}"
)
template = Template("{% load laces %}<h1>{% component my_component %}</h1>")
html = template.render(Context({"my_component": MyComponent()}))
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
self.assertEqual(result, "The result was: Rendered HTML")
def test_as_keyword_without_variable_name(self) -> None:
# The template is already parsed when the parent template is set. This is the
# moment where the parsing error is raised.
with self.assertRaises(TemplateSyntaxError) as cm:
self.set_parent_template("{% component my_component as %}")
self.assertEqual(
html, "<h1>Look, I&#x27;m running with scissors! 8&lt; 8&lt; 8&lt;</h1>"
str(cm.exception),
"'component' tag with 'as' must be followed by a variable name",
)
def test_error_on_rendering_non_component(self):
template = Template("{% load laces %}<h1>{% component my_component %}</h1>")
def test_autoescape_off_block_can_disable_escaping_of_render_html_return(
self,
) -> None:
self.component.render_html.return_value = (
"Look, I'm running with scissors! 8< 8< 8<"
)
self.set_parent_template(
"{% autoescape off %}{% component my_component %}{% endautoescape %}"
)
with self.assertRaises(ValueError) as cm:
template.render(Context({"my_component": "hello"}))
self.assertEqual(str(cm.exception), "Cannot render 'hello' as a component")
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
def test_render_as_var(self):
class MyComponent(Component):
def render_html(self, parent_context):
return format_html("<h1>I am a component</h1>")
self.assertEqual(
result,
"Look, I'm running with scissors! 8< 8< 8<",
)
template = Template(
"{% load laces %}{% component my_component as my_html %}The result was: {{ my_html }}"
def test_parsing_no_arguments(self) -> None:
# The template is already parsed when the parent template is set. This is the
# moment where the parsing error is raised.
with self.assertRaises(TemplateSyntaxError) as cm:
self.set_parent_template("{% component %}")
self.assertEqual(
str(cm.exception),
"'component' tag requires at least one argument, the component object",
)
html = template.render(Context({"my_component": MyComponent()}))
self.assertEqual(html, "The result was: <h1>I am a component</h1>")
def test_parsing_unknown_kwarg(self) -> None:
# The template is already parsed when the parent template is set. This is the
# moment where the parsing error is raised.
with self.assertRaises(TemplateSyntaxError) as cm:
self.set_parent_template("{% component my_component unknown_kwarg=True %}")
self.assertEqual(
str(cm.exception),
"'component' tag only accepts 'fallback_render_method' as a keyword argument",
)
def test_parsing_unknown_bit(self) -> None:
# The template is already parsed when the parent template is set. This is the
# moment where the parsing error is raised.
with self.assertRaises(TemplateSyntaxError) as cm:
self.set_parent_template("{% component my_component unknown_bit %}")
self.assertEqual(
str(cm.exception),
"'component' tag received an unknown argument: 'unknown_bit'",
)
+664
-85
Metadata-Version: 2.1
Name: laces
Version: 0.1.0
Version: 0.1.1
Summary: Django components that know how to render themselves.

@@ -18,2 +18,3 @@ Author-email: Tibor Leupold <tibor@lpld.io>

Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Framework :: Django

@@ -24,10 +25,22 @@ Classifier: Framework :: Django :: 3.2

Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Requires-Dist: Django>=3.2
Requires-Dist: tox==4.11.3 ; extra == "ci"
Requires-Dist: tox-gh-actions==3.1.3 ; extra == "ci"
Requires-Dist: virtualenv-pyenv==0.4.0 ; extra == "ci"
Requires-Dist: tox==4.12.1 ; extra == "dev"
Requires-Dist: tox-gh-actions==3.2.0 ; extra == "dev"
Requires-Dist: virtualenv-pyenv==0.4.0 ; extra == "dev"
Requires-Dist: coverage==7.3.4 ; extra == "dev"
Requires-Dist: pre-commit==3.4.0 ; extra == "dev"
Requires-Dist: black==24.1.1 ; extra == "dev"
Requires-Dist: blacken-docs==1.16.0 ; extra == "dev"
Requires-Dist: isort==5.13.2 ; extra == "dev"
Requires-Dist: flake8==7.0.0 ; extra == "dev"
Requires-Dist: flake8-bugbear ; extra == "dev"
Requires-Dist: flake8-comprehensions ; extra == "dev"
Requires-Dist: mypy==1.7.1 ; extra == "dev"
Requires-Dist: django-stubs[compatible-mypy]==4.2.7 ; extra == "dev"
Requires-Dist: types-requests==2.31.0.20240125 ; extra == "dev"
Requires-Dist: dj-database-url==2.1.0 ; extra == "testing"
Requires-Dist: pre-commit==3.4.0 ; extra == "testing"
Requires-Dist: coverage==7.3.4 ; extra == "testing"
Project-URL: Home, https://github.com/tbrlpld/laces
Provides-Extra: ci
Provides-Extra: dev
Provides-Extra: testing

@@ -40,2 +53,3 @@

[![laces CI](https://github.com/tbrlpld/laces/actions/workflows/test.yml/badge.svg)](https://github.com/tbrlpld/laces/actions/workflows/test.yml)
[![codecov](https://codecov.io/gh/tbrlpld/laces/graph/badge.svg?token=FMHEHNVPSX)](https://codecov.io/gh/tbrlpld/laces)

@@ -46,25 +60,38 @@ ---

Laces components provide a simple way to combine data (in the form of Python objects) with the Django templates that are meant to render that data.
The components can then be simply rendered in any other template using the `{% component %}` template tag.
That parent template does not need to know anything about the component's template or data.
No need to receive, filter, restructure or pass any data to the component's template.
Just let the component render itself.
Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications (e.g. the [Wagtail](https://github.com/wagtail/wagtail) admin interface).
This package provides tools enable and support working with such objects, also known as "components".
Template and data are tied together (sorry, not sorry 😅) in the component, and they can be passed around together.
This becomes especially useful when components are nested — it allows us to avoid building the same nested structure twice (once in the data and again in the templates).
The APIs provided in the package have previously been discovered, developed and solidified in the Wagtail project.
Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications, such as the [Wagtail](https://github.com/wagtail/wagtail) admin interface.
The Wagtail admin is also where the APIs provided in this package have previously been discovered, developed and solidified.
The purpose of this package is to make these tools available to other Django projects outside the Wagtail ecosystem.
## Links
- [Documentation](https://github.com/tbrlpld/laces/blob/main/README.md)
- [Getting started](#getting-started)
- [Installation](#installation)
- [Creating components](#creating-components)
- [Passing context to the component template](#passing-context-to-the-component-template)
- [Using components in other templates](#using-components-in-other-templates)
- [Adding JavaScript and CSS assets to a component](#adding-javascript-and-css-assets-to-a-component)
- [Patterns for using components](#patterns-for-using-components)
- [Nesting components](#nesting-components)
- [Nested groups of components](#nested-groups-of-components)
- [Container components](#container-components)
- [Using dataclasses](#using-dataclasses)
- [About Laces and components](#about-laces-and-components)
- [Contributing](#contributing)
- [Changelog](https://github.com/tbrlpld/laces/blob/main/CHANGELOG.md)
- [Contributing](https://github.com/tbrlpld/laces/blob/main/CONTRIBUTING.md)
- [Discussions](https://github.com/tbrlpld/laces/discussions)
- [Security](https://github.com/tbrlpld/laces/security)
## Supported versions
## Getting started
- Python >= 3.8
- Django >= 3.2
### Installation
## Installation
First, install with pip:

@@ -85,8 +112,5 @@ ```sh

## Usage
### Creating components
The preferred way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it.
The rendered template will then be used as the component's HTML representation:
The simplest way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it.

@@ -100,16 +124,51 @@ ```python

class WelcomePanel(Component):
template_name = "my_app/panels/welcome.html"
template_name = "my_app/components/welcome.html"
```
```html+django
{# my_app/templates/my_app/components/welcome.html #}
my_welcome_panel = WelcomePanel()
<h1>Hello World!</h1>
```
With the above in place, you then instantiate the component (e.g., in a view) and pass it to another template for rendering.
```python
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel() # <-- Instantiates the component
return render(
request,
"my_app/home.html",
{"welcome": welcome}, # <-- Passes the component to the view template
)
```
In the view template, we `load` the `laces` tag library and use the `{% component %}` tag to render the component.
```html+django
{# my_app/templates/my_app/panels/welcome.html #}
{# my_app/templates/my_app/home.html #}
<h1>Welcome to my app!</h1>
{% load laces %}
{% component welcome %} {# <-- Renders the component #}
```
For simple cases that don't require a template, the `render_html` method can be overridden instead:
That's it!
The component's template will be rendered right there in the view template.
Of course, this is a very simple example and not much more useful than using a simple `include`.
We will go into some more useful use cases below.
### Without a template
Before we dig deeper into the component use cases, just a quick note that components don't have to have a template.
For simple cases that don't require a template, the `render_html` method can be overridden instead.
If the return value contains HTML, it should be marked as safe using `django.utils.html.format_html` or `django.utils.safestring.mark_safe`.
```python

@@ -124,10 +183,20 @@ # my_app/components.py

def render_html(self, parent_context):
return format_html("<h1>{}</h1>", "Welcome to my app!")
return format_html("<h1>Hello World!</h1>")
```
### Passing context to the template
### Passing context to the component template
The `get_context_data` method can be overridden to pass context variables to the template.
As with `render_html`, this receives the context dictionary from the calling template.
Now back to components with templates.
The example shown above with the static welcome message in the template is, of course, not very useful.
It seems more like an overcomplicated way to replace a simple `include`.
But, we rarely ever want to render templates with static content.
Usually, we want to pass some context variables to the template to be rendered.
This is where components start to become interesting.
The default implementation of `render_html` calls the component's `get_context_data` method to get the context variables to pass to the template.
The default implementation of `get_context_data` returns an empty dictionary.
To customize the context variables passed to the template, we can override `get_context_data`.
```python

@@ -140,20 +209,25 @@ # my_app/components.py

class WelcomePanel(Component):
template_name = "my_app/panels/welcome.html"
template_name = "my_app/components/welcome.html"
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["username"] = parent_context["request"].user.username
return context
return {"name": "Alice"}
```
```html+django
{# my_app/templates/my_app/panels/welcome.html #}
{# my_app/templates/my_app/components/welcome.html #}
<h1>Welcome to my app, {{ username }}!</h1>
<h1>Hello {{ name }}</h1>
```
### Adding media definitions
With the above we are now rendering a welcome message with the name coming from the component's `get_context_data` method.
Nice.
But, still not very useful, as the name is still hardcoded — in the component method instead of the template, but hardcoded nonetheless.
Like Django form widgets, components can specify associated JavaScript and CSS resources using either an inner `Media` class or a dynamic `media` property.
#### Using class properties
When considering how to make the context of our components more useful, it's helpful to remember that components are just normal Python classes and objects.
So, you are basically free to get the context data into the component in any way you like.
For example, we can pass arguments to the constructor and use them in the component's methods, like `get_context_data`.
```python

@@ -166,14 +240,70 @@ # my_app/components.py

class WelcomePanel(Component):
template_name = "my_app/panels/welcome.html"
template_name = "my_app/components/welcome.html"
class Media:
css = {"all": ("my_app/css/welcome-panel.css",)}
def __init__(self, name):
self.name = name
def get_context_data(self, parent_context):
return {"name": self.name}
```
Nice, this is getting better.
Now we can pass the name to the component when we instantiate it and pass the component ready to be rendered to the view template.
```python
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel(name="Alice")
return render(
request,
"my_app/home.html",
{"welcome": welcome},
)
```
So, as mentioned before, we can use the full power of Python classes and objects to provide context data to our components.
A couple more examples of how components can be used can be found [below](#patterns-for-using-components).
#### Using the parent context
You may have noticed in the above examples that the `render_html` and `get_context_data` methods take a `parent_context` argument.
This is the context of the template that is calling the component.
The `parent_context` is passed into the `render_html` method by the `{% component %}` template tag.
In the default implementation of the `render_html` method, the `parent_context` is then passed to the `get_context_data` method.
The default implementation of the `get_context_data` method, however, ignores the `parent_context` argument and returns an empty dictionary.
To make use of it, you will have to override the `get_context_data` method.
Relying on data from the parent context somewhat forgoes some of the benefits of components, which is tying the data and template together.
Especially for nested uses of components, you now require that the data in the right format is passed through all layers of templates again.
It is usually cleaner to provide all the data needed by the component directly to the component itself.
However, there may be cases where this is not possible or desirable.
For those cases, you have access to the parent context in the component's `get_context_data` method.
```python
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
def get_context_data(self, parent_context):
return {"name": parent_context["request"].user.first_name}
```
(Of course, this could have also been achieved by passing the request or user object to the component in the view, but this is just an example.)
### Using components in other templates
The `laces` tag library provides a `{% component %}` tag for including components on a template.
This takes care of passing context variables from the calling template to the component (which would not be the case for a basic `{{ ... }}` variable tag).
As mentioned in the [first example](#creating-components), components are rendered in other templates using the `{% component %}` tag from the `laces` tag library.
For example, given the view passes an instance of `WelcomePanel` to the context of `my_app/welcome.html`.
Here is that example from above again, in which the view passes an instance of `WelcomePanel` to the context of `my_app/home.html`.

@@ -188,10 +318,10 @@ ```python

def welcome_page(request):
panel = (WelcomePanel(),)
def home(request):
welcome = WelcomePanel()
return render(
request,
"my_app/welcome.html",
"my_app/home.html",
{
"panel": panel,
"welcome": welcome,
},

@@ -201,58 +331,152 @@ )

The template `my_app/templates/my_app/welcome.html` could render the panel as follows:
Then, in the `my_app/templates/my_app/home.html` template we render the welcome panel component as follows:
```html+django
{# my_app/templates/my_app/welcome.html #}
{# my_app/templates/my_app/home.html #}
{% load laces %}
{% component panel %}
{% component welcome %}
```
You can pass additional context variables to the component using the keyword `with`:
This is the basic usage of components and should cover most cases.
However, the `{% component %}` tag also supports some additional features.
Specifically, the keywords `with`, `only` and `as` are supported, similar to how they work with the [`{% include %}`](https://docs.djangoproject.com/en/5.0/ref/templates/builtins/#std-templatetag-include) tag.
#### Provide additional parent context variables with `with`
You can pass additional parent context variables to the component using the keyword `with`:
```html+django
{% component panel with username=request.user.username %}
{% component welcome with name=request.user.first_name %}
```
To render the component with only the variables provided (and no others from the calling template's context), use `only`:
**Note**: These extra variables will be added to the `parent_context` which is passed to the component's `render_html` and `get_context_data` methods.
The default implementation of `get_context_data` ignores the `parent_context` argument, so you will have to override it to make use of the extra variables.
For more information see the above section on the [parent context](#using-the-parent-context).
#### Limit the parent context variables with `only`
To limit the parent context variables passed to the component to only those variables provided by the `with` keyword (and no others from the calling template's context), use `only`:
```html+django
{% component panel with username=request.user.username only %}
{% component welcome with name=request.user.first_name only %}
```
**Note**: Both, `with` and `only`, only affect the `parent_context` which is passed to the component's `render_html` and `get_context_data` methods. They do not have any direct effect on actual context that is passed to the component's template. E.g. if the component's `get_context_data` method returns a dictionary which always contains a key `foo`, then that key will be available in the component's template, regardless of whether `only` was used or not.
#### Store the rendered output in a variable with `as`
To store the component's rendered output in a variable rather than outputting it immediately, use `as` followed by the variable name:
```html+django
{% component panel as panel_html %}
{% component welcome as welcome_html %}
{{ panel_html }}
{{ welcome_html }}
```
Note that it is your template's responsibility to output any media declarations defined on the components.
This can be done by constructing a media object for the whole page within the view, passing this to the template, and outputting it via `media.js` and `media.css`.
### Adding JavaScript and CSS assets to a component
Like Django form widgets, components can specify associated JavaScript and CSS assets.
The assets for a component can be specified in the same way that [Django form assets are defined](https://docs.djangoproject.com/en/5.0/topics/forms/media).
This can be achieved using either an inner `Media` class or a dynamic `media` property.
An inner `Media` class definition looks like this:
```python
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
class Media:
css = {"all": ("my_app/css/welcome-panel.css",)}
```
The more dynamic definition via a `media` property looks like this:
```python
# my_app/components.py
from django.forms import Media
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
@property
def media(self):
return Media(css={"all": ("my_app/css/welcome-panel.css",)})
```
**Note**:
It is your template's responsibility to output any media declarations defined on the components.
#### Outputting component media in templates
Once you have defined the assets on the component in one of the two ways above, you can output them in your templates.
This, again, works in the same way as it does for Django form widgets.
The component instance will have a `media` property which returns an instance of the `django.forms.Media` class.
This is the case, even if you used the nested `Media` class to define the assets.
The [string representation of a `Media` objects](https://docs.djangoproject.com/en/5.0/topics/forms/media#s-media-objects) are the HTML declarations to include the assets.
In the example home template from above, we can output the component's media declarations like so:
```html+django
{# my_app/templates/my_app/home.html #}
{% load laces %}
<head>
{{ welcome.media }}
<head>
<body>
{% component welcome %}
</body>
```
#### Combining media with `MediaContainer`
When you have many components in a page, it can be cumbersome to output the media declarations for each component individually.
To make that process a bit easier, Laces provides a `MediaContainer` class.
The `MediaContainer` class is a subclass of Python's built-in `list` class which combines the `media` of all it's members.
In a view we can create a `MediaContainer` instance containing several media-defining components and pass it to the view template.
```python
# my_app/views.py
from django.forms import Media
from django.shortcuts import render
from laces.components import MediaContainer
from my_app.components import WelcomePanel
from my_app.components import (
Dashboard,
Footer,
Header,
Sidebar,
WelcomePanel,
)
def welcome_page(request):
panels = [
WelcomePanel(),
]
def home(request):
components = MediaContainer(
[
Header(),
Sidebar(),
WelcomePanel(),
Dashboard(),
Footer(),
]
)
media = Media()
for panel in panels:
media += panel.media
render(
return render(
request,
"my_app/welcome.html",
"my_app/home.html",
{
"panels": panels,
"media": media,
"components": components,
},

@@ -262,5 +486,6 @@ )

Then, in the view template, we can output the media declarations for all components in the container at once.
```html+django
{# my_app/templates/my_app/welcome.html #}
{# my_app/templates/my_app/home.html #}

@@ -270,12 +495,321 @@ {% load laces %}

<head>
{{ media.js }}
{{ media.css }}
{{ components.media }}
<head>
<body>
{% for component in components %}
{% component component %}
{% endfor %}
</body>
```
This will output a combined media declaration for all components in the container.
The combination of the media declarations follows the behaviour outlined in the [Django documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#combining-media-objects).
**Note**:
The use of `MediaContainer` is not limited to contain components.
It can be used to combine the `media` properties of any kind of objects that have a `media` property.
## Patterns for using components
Below, we want to show a few more examples of how components can be used that were not covered in the ["Getting started" section](#getting-started) above.
### Nesting components
The combination of data and template that components provide becomes especially useful when components are nested.
```python
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component): ...
class Dashboard(Component):
template_name = "my_app/components/dashboard.html"
def __init__(self, user):
self.welcome = WelcomePanel(name=user.first_name)
...
def get_context_data(self, parent_context):
return {"welcome": self.welcome}
```
The template of the "parent" component does not need to know anything about the "child" component, except for which template variable is a component.
The child component already contains the data it needs and knows which template to use to render that data.
```html+django
{# my_app/templates/my_app/components/dashboard.html #}
{% load laces %}
<div class="dashboard">
{% component welcome %}
...
</div>
```
The nesting also provides us with a nice data structure we can test.
```python
dashboard = Dashboard(user=request.user)
assert dashboard.welcome.name == request.user.first_name
```
### Nested groups of components
The nesting of components is not limited to single instances.
We can also nest groups of components.
```python
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component): ...
class UsagePanel(Component): ...
class TeamPanel(Component): ...
class Dashboard(Component):
template_name = "my_app/components/dashboard.html"
def __init__(self, user):
self.panels = [
WelcomePanel(name=user.first_name),
UsagePanel(user=user),
TeamPanel(groups=user.groups.all()),
]
...
def get_context_data(self, parent_context):
return {"panels": self.panels}
```
```html+django
{# my_app/templates/my_app/components/dashboard.html #}
{% load laces %}
<div class="dashboard">
{% for panel in panels %}
{% component panel %}
{% endfor %}
...
</div>
```
### Container components
The [above example](#nested-groups-of-components) is relatively static.
The `Dashboard` component always contains the same panels.
You could also imagine passing the child components in through the constructor.
This would make your component into a dynamic container component.
```python
# my_app/components.py
from laces.components import Component
class Section(Component):
template_name = "my_app/components/section.html"
def __init__(self, children: list[Component]):
self.children = children
...
def get_context_data(self, parent_context):
return {"children": self.children}
class Heading(Component): ...
class Paragraph(Component): ...
class Image(Component): ...
```
```html+django
{# my_app/templates/my_app/components/section.html #}
{% load laces %}
<section>
{% for child in children %}
{% component child %}
{% endfor %}
</section>
```
The above `Section` component can take any kind of component as children.
The only thing that `Section` requires is that the children can be rendered with the `{% component %}` tag (which all components do).
In the view, we can now instantiate the `Section` component with any children we want.
```python
# my_app/views.py
from django.shortcuts import render
from my_app.components import (
Heading,
Image,
Paragraph,
Section,
)
def home(request):
content = Section(
children=[
Heading(...),
Paragraph(...),
Image(...),
]
)
return render(
request,
"my_app/home.html",
{"content": content},
)
```
```html+django
{# my_app/templates/my_app/home.html #}
{% load laces %}
<body>
{% component content %}
...
</body>
```
### Using dataclasses
Above, we showed how to [use class properties](#using-class-properties) to add data to the component's context.
This is a very useful and common pattern.
However, it is a bit verbose, especially when you have many properties and directly pass the properties to the template context.
To make this a little more convenient, we can use [`dataclasses`](https://docs.python.org/3.12/library/dataclasses.html#module-dataclasses).
```python
# my_app/components.py
from dataclasses import dataclass, asdict
from laces.components import Component
@dataclass
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
name: str
def get_context_data(self, parent_context):
return asdict(self)
```
With dataclasses we define the name and type of the properties we want to pass to the component in the class definition.
Then, we can use the `asdict` function to convert the dataclass instance to a dictionary that can be directly as the template context.
The `asdict` function only adds keys to the dictionary that were defined as the properties defined in the dataclass.
In the above example, the dictionary returned by `asdict` would only contain the `name` key.
It would not contain the `template_name` key, because that is set on the class with a value but without a type annotation.
If you were to add the type annotation, then the `template_name` key would also be included in the dictionary returned by `asdict`.
### Custom constructor methods
When a component has many properties, it can be a pain to pass each property to the constructor individually.
This is especially true when the component is used in many places and the data preparation would need to be repeated in each use case.
Custom constructor methods can help with that.
In case of our `WelcomePanel` example, we might want to show some more user information, including a profile image and link to the user's profile page.
We can add a `classmethod` that takes the user object and returns an instance of the component with all the data needed to render the component.
We can also use this method to encapsulate the logic for generating additional data, such as the profile URL.
```python
# my_app/components.py
from django import urls
from dataclasses import dataclass, asdict
from laces.components import Component
@dataclass
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
first_name: str
last_name: str
profile_url: str
profile_image_url: str
@classmethod
def from_user(cls, user):
profile_url = urls.reverse("profile", kwargs={"pk": user.pk})
return cls(
first_name=user.first_name,
last_name=user.last_name,
profile_url=profile_url,
profile_image_url=user.profile.image.url,
)
def get_context_data(self, parent_context):
return asdict(self)
```
Now, we can instantiate the component in the view like so:
```python
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel.from_user(request.user)
return render(
request,
"my_app/home.html",
{"welcome": welcome},
)
```
The constructor method allows us to keep our view very simple and clean as all the data preparation is encapsulated in the component.
As in the example above, custom constructor methods pair very well with the use of dataclasses, but they can of course also be used without them.
## About Laces and components
### Why "Laces"?
"Laces" is somewhat of a reference to the feature of tying data and templates together.
The components are also "self-rendering," which could be seen as "self-reliance," which relates to "bootstrapping."
And aren't "bootstraps" just a long kind of "(shoe)laces?"
Finally, it is a nod to [@mixxorz](https://github.com/mixxorz)'s fantastic [Slippers package](https://github.com/mixxorz/slippers), which also takes a component focused approach to improve the experience when working with Django templates, but in a quite different way.
### Supported versions
- Python >= 3.8
- Django >= 3.2
## Contributing

@@ -292,3 +826,3 @@

With your preferred virtualenv activated, install testing dependencies:
With your preferred virtualenv activated, install the development dependencies:

@@ -299,3 +833,3 @@ #### Using pip

$ python -m pip install --upgrade pip>=21.3
$ python -m pip install -e '.[testing]' -U
$ python -m pip install -e '.[dev]' -U
```

@@ -316,4 +850,2 @@

```shell
# go to the project directory
$ cd laces
# initialize pre-commit

@@ -337,3 +869,3 @@ $ pre-commit install

```sh
$ tox -e python3.11-django4.2-wagtail5.1
$ tox -e python3.11-django4.2
```

@@ -344,3 +876,3 @@

```sh
$ tox -e python3.11-django4.2-wagtail5.1-sqlite laces.tests.test_file.TestClass.test_method
$ tox -e python3.11-django4.2 laces.tests.test_file.TestClass.test_method
```

@@ -356,8 +888,48 @@

#### Testing with coverage
`tox` is configured to run tests with coverage.
The coverage report is combined for all environments.
This is done by using the `--append` flag when running coverage in `tox`.
This means it will also include previous results.
You can see the coverage report by running:
```sh
$ coverage report
```
To get a clean report, you can run `coverage erase` before running `tox`.
#### Running tests without `tox`
If you want to run tests without `tox`, you can use the `testmanage.py` script.
This script is a wrapper around Django's `manage.py` and will run tests with the correct settings.
To make this work, you need to have the `testing` dependencies installed.
```sh
$ python -m pip install -e '.[testing]' -U
```
Then you can run tests with:
```sh
$ ./testmanage.py test
````
To run tests with coverage, use:
```sh
$ coverage run ./testmanage.py test
```
### Python version management
Tox will attempt to find installed Python versions on your machine.
If you use `pyenv` to manage multiple versions, you can tell `tox` to use those versions.
This working, is depended on [`virtualenv-pyenv`](https://pypi.org/project/virtualenv-pyenv/) (note: this is not `pyenv-virtualenv`) which is part of the CI dependencies (just like `tox` itself is).
To enable the use, you want to set the environment variable `VIRTUALENV_DISCOVERY=pyenv`.
To ensure that `tox` will find Python versions installed with `pyenv` you need [`virtualenv-pyenv`](https://pypi.org/project/virtualenv-pyenv/) (note: this is not `pyenv-virtualenv`).
`virtualenv-pyenv` is part of the development dependencies (just like `tox` itself).
Additionally, you have to set the environment variable `VIRTUALENV_DISCOVERY=pyenv`.

@@ -369,4 +941,11 @@ ### Publishing

Before publishing a new release, make sure to update the changelog in `CHANGELOG.md` and the version number in `laces/__init__.py`.
To create a release, you need a Git tag.
The tag can either be created on the command line and pushed or in the "create release" interface on GitHub.
The tag name should be the version number prefixed with a `v` (e.g. `v0.1.0`).
Before publishing a new release, make sure to update
- the changelog in `CHANGELOG.md`, and
- the version number in `laces/__init__.py`.
To manually test publishing the package, you can use `flit`.

@@ -373,0 +952,0 @@ Be sure to configure the `testpypi` repository in your `~/.pypirc` file according to the Flit [documentation](https://flit.pypa.io/en/stable/upload.html#controlling-package-uploads).

@@ -22,2 +22,3 @@ [build-system]

"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Framework :: Django",

@@ -28,2 +29,3 @@ "Framework :: Django :: 3.2",

"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
]

@@ -38,9 +40,25 @@ requires-python = ">=3.8"

"dj-database-url==2.1.0",
"pre-commit==3.4.0"
# Running tests with coverage inside the tox test environment.
"coverage==7.3.4",
]
ci = [
"tox==4.11.3",
"tox-gh-actions==3.1.3",
dev = [
"tox==4.12.1",
"tox-gh-actions==3.2.0",
# Allow use of pyenv for virtual environments. To enable you need to set `VIRTUALENV_DISCOVERY=pyenv` in the shell.
"virtualenv-pyenv==0.4.0"
# This is useful to help tox find the correct python version when using pyenv.
"virtualenv-pyenv==0.4.0",
# This is to have coverage available in the development environment.
# It's not great that it's duplicated, but I can't find a way to make it work with tox otherwise.
"coverage==7.3.4",
# Linting etc.
"pre-commit==3.4.0",
"black==24.1.1",
"blacken-docs==1.16.0",
"isort==5.13.2",
"flake8==7.0.0",
"flake8-bugbear",
"flake8-comprehensions",
"mypy==1.7.1",
"django-stubs[compatible-mypy]==4.2.7",
"types-requests==2.31.0.20240125",
]

@@ -66,1 +84,9 @@

]
[tool.mypy]
files = ["."]
plugins = ["mypy_django_plugin.main"]
strict = true
[tool.django-stubs]
django_settings_module = "laces.test.settings"
+645
-79

@@ -6,2 +6,3 @@ # Laces

[![laces CI](https://github.com/tbrlpld/laces/actions/workflows/test.yml/badge.svg)](https://github.com/tbrlpld/laces/actions/workflows/test.yml)
[![codecov](https://codecov.io/gh/tbrlpld/laces/graph/badge.svg?token=FMHEHNVPSX)](https://codecov.io/gh/tbrlpld/laces)

@@ -12,25 +13,38 @@ ---

Laces components provide a simple way to combine data (in the form of Python objects) with the Django templates that are meant to render that data.
The components can then be simply rendered in any other template using the `{% component %}` template tag.
That parent template does not need to know anything about the component's template or data.
No need to receive, filter, restructure or pass any data to the component's template.
Just let the component render itself.
Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications (e.g. the [Wagtail](https://github.com/wagtail/wagtail) admin interface).
This package provides tools enable and support working with such objects, also known as "components".
Template and data are tied together (sorry, not sorry 😅) in the component, and they can be passed around together.
This becomes especially useful when components are nested — it allows us to avoid building the same nested structure twice (once in the data and again in the templates).
The APIs provided in the package have previously been discovered, developed and solidified in the Wagtail project.
Working with objects that know how to render themselves as HTML elements is a common pattern found in complex Django applications, such as the [Wagtail](https://github.com/wagtail/wagtail) admin interface.
The Wagtail admin is also where the APIs provided in this package have previously been discovered, developed and solidified.
The purpose of this package is to make these tools available to other Django projects outside the Wagtail ecosystem.
## Links
- [Documentation](https://github.com/tbrlpld/laces/blob/main/README.md)
- [Getting started](#getting-started)
- [Installation](#installation)
- [Creating components](#creating-components)
- [Passing context to the component template](#passing-context-to-the-component-template)
- [Using components in other templates](#using-components-in-other-templates)
- [Adding JavaScript and CSS assets to a component](#adding-javascript-and-css-assets-to-a-component)
- [Patterns for using components](#patterns-for-using-components)
- [Nesting components](#nesting-components)
- [Nested groups of components](#nested-groups-of-components)
- [Container components](#container-components)
- [Using dataclasses](#using-dataclasses)
- [About Laces and components](#about-laces-and-components)
- [Contributing](#contributing)
- [Changelog](https://github.com/tbrlpld/laces/blob/main/CHANGELOG.md)
- [Contributing](https://github.com/tbrlpld/laces/blob/main/CONTRIBUTING.md)
- [Discussions](https://github.com/tbrlpld/laces/discussions)
- [Security](https://github.com/tbrlpld/laces/security)
## Supported versions
## Getting started
- Python >= 3.8
- Django >= 3.2
### Installation
## Installation
First, install with pip:

@@ -51,8 +65,5 @@ ```sh

## Usage
### Creating components
The preferred way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it.
The rendered template will then be used as the component's HTML representation:
The simplest way to create a component is to define a subclass of `laces.components.Component` and specify a `template_name` attribute on it.

@@ -66,16 +77,51 @@ ```python

class WelcomePanel(Component):
template_name = "my_app/panels/welcome.html"
template_name = "my_app/components/welcome.html"
```
```html+django
{# my_app/templates/my_app/components/welcome.html #}
my_welcome_panel = WelcomePanel()
<h1>Hello World!</h1>
```
With the above in place, you then instantiate the component (e.g., in a view) and pass it to another template for rendering.
```python
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel() # <-- Instantiates the component
return render(
request,
"my_app/home.html",
{"welcome": welcome}, # <-- Passes the component to the view template
)
```
In the view template, we `load` the `laces` tag library and use the `{% component %}` tag to render the component.
```html+django
{# my_app/templates/my_app/panels/welcome.html #}
{# my_app/templates/my_app/home.html #}
<h1>Welcome to my app!</h1>
{% load laces %}
{% component welcome %} {# <-- Renders the component #}
```
For simple cases that don't require a template, the `render_html` method can be overridden instead:
That's it!
The component's template will be rendered right there in the view template.
Of course, this is a very simple example and not much more useful than using a simple `include`.
We will go into some more useful use cases below.
### Without a template
Before we dig deeper into the component use cases, just a quick note that components don't have to have a template.
For simple cases that don't require a template, the `render_html` method can be overridden instead.
If the return value contains HTML, it should be marked as safe using `django.utils.html.format_html` or `django.utils.safestring.mark_safe`.
```python

@@ -90,10 +136,20 @@ # my_app/components.py

def render_html(self, parent_context):
return format_html("<h1>{}</h1>", "Welcome to my app!")
return format_html("<h1>Hello World!</h1>")
```
### Passing context to the template
### Passing context to the component template
The `get_context_data` method can be overridden to pass context variables to the template.
As with `render_html`, this receives the context dictionary from the calling template.
Now back to components with templates.
The example shown above with the static welcome message in the template is, of course, not very useful.
It seems more like an overcomplicated way to replace a simple `include`.
But, we rarely ever want to render templates with static content.
Usually, we want to pass some context variables to the template to be rendered.
This is where components start to become interesting.
The default implementation of `render_html` calls the component's `get_context_data` method to get the context variables to pass to the template.
The default implementation of `get_context_data` returns an empty dictionary.
To customize the context variables passed to the template, we can override `get_context_data`.
```python

@@ -106,20 +162,25 @@ # my_app/components.py

class WelcomePanel(Component):
template_name = "my_app/panels/welcome.html"
template_name = "my_app/components/welcome.html"
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["username"] = parent_context["request"].user.username
return context
return {"name": "Alice"}
```
```html+django
{# my_app/templates/my_app/panels/welcome.html #}
{# my_app/templates/my_app/components/welcome.html #}
<h1>Welcome to my app, {{ username }}!</h1>
<h1>Hello {{ name }}</h1>
```
### Adding media definitions
With the above we are now rendering a welcome message with the name coming from the component's `get_context_data` method.
Nice.
But, still not very useful, as the name is still hardcoded — in the component method instead of the template, but hardcoded nonetheless.
Like Django form widgets, components can specify associated JavaScript and CSS resources using either an inner `Media` class or a dynamic `media` property.
#### Using class properties
When considering how to make the context of our components more useful, it's helpful to remember that components are just normal Python classes and objects.
So, you are basically free to get the context data into the component in any way you like.
For example, we can pass arguments to the constructor and use them in the component's methods, like `get_context_data`.
```python

@@ -132,14 +193,70 @@ # my_app/components.py

class WelcomePanel(Component):
template_name = "my_app/panels/welcome.html"
template_name = "my_app/components/welcome.html"
class Media:
css = {"all": ("my_app/css/welcome-panel.css",)}
def __init__(self, name):
self.name = name
def get_context_data(self, parent_context):
return {"name": self.name}
```
Nice, this is getting better.
Now we can pass the name to the component when we instantiate it and pass the component ready to be rendered to the view template.
```python
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel(name="Alice")
return render(
request,
"my_app/home.html",
{"welcome": welcome},
)
```
So, as mentioned before, we can use the full power of Python classes and objects to provide context data to our components.
A couple more examples of how components can be used can be found [below](#patterns-for-using-components).
#### Using the parent context
You may have noticed in the above examples that the `render_html` and `get_context_data` methods take a `parent_context` argument.
This is the context of the template that is calling the component.
The `parent_context` is passed into the `render_html` method by the `{% component %}` template tag.
In the default implementation of the `render_html` method, the `parent_context` is then passed to the `get_context_data` method.
The default implementation of the `get_context_data` method, however, ignores the `parent_context` argument and returns an empty dictionary.
To make use of it, you will have to override the `get_context_data` method.
Relying on data from the parent context somewhat forgoes some of the benefits of components, which is tying the data and template together.
Especially for nested uses of components, you now require that the data in the right format is passed through all layers of templates again.
It is usually cleaner to provide all the data needed by the component directly to the component itself.
However, there may be cases where this is not possible or desirable.
For those cases, you have access to the parent context in the component's `get_context_data` method.
```python
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
def get_context_data(self, parent_context):
return {"name": parent_context["request"].user.first_name}
```
(Of course, this could have also been achieved by passing the request or user object to the component in the view, but this is just an example.)
### Using components in other templates
The `laces` tag library provides a `{% component %}` tag for including components on a template.
This takes care of passing context variables from the calling template to the component (which would not be the case for a basic `{{ ... }}` variable tag).
As mentioned in the [first example](#creating-components), components are rendered in other templates using the `{% component %}` tag from the `laces` tag library.
For example, given the view passes an instance of `WelcomePanel` to the context of `my_app/welcome.html`.
Here is that example from above again, in which the view passes an instance of `WelcomePanel` to the context of `my_app/home.html`.

@@ -154,10 +271,10 @@ ```python

def welcome_page(request):
panel = (WelcomePanel(),)
def home(request):
welcome = WelcomePanel()
return render(
request,
"my_app/welcome.html",
"my_app/home.html",
{
"panel": panel,
"welcome": welcome,
},

@@ -167,58 +284,152 @@ )

The template `my_app/templates/my_app/welcome.html` could render the panel as follows:
Then, in the `my_app/templates/my_app/home.html` template we render the welcome panel component as follows:
```html+django
{# my_app/templates/my_app/welcome.html #}
{# my_app/templates/my_app/home.html #}
{% load laces %}
{% component panel %}
{% component welcome %}
```
You can pass additional context variables to the component using the keyword `with`:
This is the basic usage of components and should cover most cases.
However, the `{% component %}` tag also supports some additional features.
Specifically, the keywords `with`, `only` and `as` are supported, similar to how they work with the [`{% include %}`](https://docs.djangoproject.com/en/5.0/ref/templates/builtins/#std-templatetag-include) tag.
#### Provide additional parent context variables with `with`
You can pass additional parent context variables to the component using the keyword `with`:
```html+django
{% component panel with username=request.user.username %}
{% component welcome with name=request.user.first_name %}
```
To render the component with only the variables provided (and no others from the calling template's context), use `only`:
**Note**: These extra variables will be added to the `parent_context` which is passed to the component's `render_html` and `get_context_data` methods.
The default implementation of `get_context_data` ignores the `parent_context` argument, so you will have to override it to make use of the extra variables.
For more information see the above section on the [parent context](#using-the-parent-context).
#### Limit the parent context variables with `only`
To limit the parent context variables passed to the component to only those variables provided by the `with` keyword (and no others from the calling template's context), use `only`:
```html+django
{% component panel with username=request.user.username only %}
{% component welcome with name=request.user.first_name only %}
```
**Note**: Both, `with` and `only`, only affect the `parent_context` which is passed to the component's `render_html` and `get_context_data` methods. They do not have any direct effect on actual context that is passed to the component's template. E.g. if the component's `get_context_data` method returns a dictionary which always contains a key `foo`, then that key will be available in the component's template, regardless of whether `only` was used or not.
#### Store the rendered output in a variable with `as`
To store the component's rendered output in a variable rather than outputting it immediately, use `as` followed by the variable name:
```html+django
{% component panel as panel_html %}
{% component welcome as welcome_html %}
{{ panel_html }}
{{ welcome_html }}
```
Note that it is your template's responsibility to output any media declarations defined on the components.
This can be done by constructing a media object for the whole page within the view, passing this to the template, and outputting it via `media.js` and `media.css`.
### Adding JavaScript and CSS assets to a component
Like Django form widgets, components can specify associated JavaScript and CSS assets.
The assets for a component can be specified in the same way that [Django form assets are defined](https://docs.djangoproject.com/en/5.0/topics/forms/media).
This can be achieved using either an inner `Media` class or a dynamic `media` property.
An inner `Media` class definition looks like this:
```python
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
class Media:
css = {"all": ("my_app/css/welcome-panel.css",)}
```
The more dynamic definition via a `media` property looks like this:
```python
# my_app/components.py
from django.forms import Media
from laces.components import Component
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
@property
def media(self):
return Media(css={"all": ("my_app/css/welcome-panel.css",)})
```
**Note**:
It is your template's responsibility to output any media declarations defined on the components.
#### Outputting component media in templates
Once you have defined the assets on the component in one of the two ways above, you can output them in your templates.
This, again, works in the same way as it does for Django form widgets.
The component instance will have a `media` property which returns an instance of the `django.forms.Media` class.
This is the case, even if you used the nested `Media` class to define the assets.
The [string representation of a `Media` objects](https://docs.djangoproject.com/en/5.0/topics/forms/media#s-media-objects) are the HTML declarations to include the assets.
In the example home template from above, we can output the component's media declarations like so:
```html+django
{# my_app/templates/my_app/home.html #}
{% load laces %}
<head>
{{ welcome.media }}
<head>
<body>
{% component welcome %}
</body>
```
#### Combining media with `MediaContainer`
When you have many components in a page, it can be cumbersome to output the media declarations for each component individually.
To make that process a bit easier, Laces provides a `MediaContainer` class.
The `MediaContainer` class is a subclass of Python's built-in `list` class which combines the `media` of all it's members.
In a view we can create a `MediaContainer` instance containing several media-defining components and pass it to the view template.
```python
# my_app/views.py
from django.forms import Media
from django.shortcuts import render
from laces.components import MediaContainer
from my_app.components import WelcomePanel
from my_app.components import (
Dashboard,
Footer,
Header,
Sidebar,
WelcomePanel,
)
def welcome_page(request):
panels = [
WelcomePanel(),
]
def home(request):
components = MediaContainer(
[
Header(),
Sidebar(),
WelcomePanel(),
Dashboard(),
Footer(),
]
)
media = Media()
for panel in panels:
media += panel.media
render(
return render(
request,
"my_app/welcome.html",
"my_app/home.html",
{
"panels": panels,
"media": media,
"components": components,
},

@@ -228,5 +439,6 @@ )

Then, in the view template, we can output the media declarations for all components in the container at once.
```html+django
{# my_app/templates/my_app/welcome.html #}
{# my_app/templates/my_app/home.html #}

@@ -236,12 +448,321 @@ {% load laces %}

<head>
{{ media.js }}
{{ media.css }}
{{ components.media }}
<head>
<body>
{% for component in components %}
{% component component %}
{% endfor %}
</body>
```
This will output a combined media declaration for all components in the container.
The combination of the media declarations follows the behaviour outlined in the [Django documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#combining-media-objects).
**Note**:
The use of `MediaContainer` is not limited to contain components.
It can be used to combine the `media` properties of any kind of objects that have a `media` property.
## Patterns for using components
Below, we want to show a few more examples of how components can be used that were not covered in the ["Getting started" section](#getting-started) above.
### Nesting components
The combination of data and template that components provide becomes especially useful when components are nested.
```python
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component): ...
class Dashboard(Component):
template_name = "my_app/components/dashboard.html"
def __init__(self, user):
self.welcome = WelcomePanel(name=user.first_name)
...
def get_context_data(self, parent_context):
return {"welcome": self.welcome}
```
The template of the "parent" component does not need to know anything about the "child" component, except for which template variable is a component.
The child component already contains the data it needs and knows which template to use to render that data.
```html+django
{# my_app/templates/my_app/components/dashboard.html #}
{% load laces %}
<div class="dashboard">
{% component welcome %}
...
</div>
```
The nesting also provides us with a nice data structure we can test.
```python
dashboard = Dashboard(user=request.user)
assert dashboard.welcome.name == request.user.first_name
```
### Nested groups of components
The nesting of components is not limited to single instances.
We can also nest groups of components.
```python
# my_app/components.py
from laces.components import Component
class WelcomePanel(Component): ...
class UsagePanel(Component): ...
class TeamPanel(Component): ...
class Dashboard(Component):
template_name = "my_app/components/dashboard.html"
def __init__(self, user):
self.panels = [
WelcomePanel(name=user.first_name),
UsagePanel(user=user),
TeamPanel(groups=user.groups.all()),
]
...
def get_context_data(self, parent_context):
return {"panels": self.panels}
```
```html+django
{# my_app/templates/my_app/components/dashboard.html #}
{% load laces %}
<div class="dashboard">
{% for panel in panels %}
{% component panel %}
{% endfor %}
...
</div>
```
### Container components
The [above example](#nested-groups-of-components) is relatively static.
The `Dashboard` component always contains the same panels.
You could also imagine passing the child components in through the constructor.
This would make your component into a dynamic container component.
```python
# my_app/components.py
from laces.components import Component
class Section(Component):
template_name = "my_app/components/section.html"
def __init__(self, children: list[Component]):
self.children = children
...
def get_context_data(self, parent_context):
return {"children": self.children}
class Heading(Component): ...
class Paragraph(Component): ...
class Image(Component): ...
```
```html+django
{# my_app/templates/my_app/components/section.html #}
{% load laces %}
<section>
{% for child in children %}
{% component child %}
{% endfor %}
</section>
```
The above `Section` component can take any kind of component as children.
The only thing that `Section` requires is that the children can be rendered with the `{% component %}` tag (which all components do).
In the view, we can now instantiate the `Section` component with any children we want.
```python
# my_app/views.py
from django.shortcuts import render
from my_app.components import (
Heading,
Image,
Paragraph,
Section,
)
def home(request):
content = Section(
children=[
Heading(...),
Paragraph(...),
Image(...),
]
)
return render(
request,
"my_app/home.html",
{"content": content},
)
```
```html+django
{# my_app/templates/my_app/home.html #}
{% load laces %}
<body>
{% component content %}
...
</body>
```
### Using dataclasses
Above, we showed how to [use class properties](#using-class-properties) to add data to the component's context.
This is a very useful and common pattern.
However, it is a bit verbose, especially when you have many properties and directly pass the properties to the template context.
To make this a little more convenient, we can use [`dataclasses`](https://docs.python.org/3.12/library/dataclasses.html#module-dataclasses).
```python
# my_app/components.py
from dataclasses import dataclass, asdict
from laces.components import Component
@dataclass
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
name: str
def get_context_data(self, parent_context):
return asdict(self)
```
With dataclasses we define the name and type of the properties we want to pass to the component in the class definition.
Then, we can use the `asdict` function to convert the dataclass instance to a dictionary that can be directly as the template context.
The `asdict` function only adds keys to the dictionary that were defined as the properties defined in the dataclass.
In the above example, the dictionary returned by `asdict` would only contain the `name` key.
It would not contain the `template_name` key, because that is set on the class with a value but without a type annotation.
If you were to add the type annotation, then the `template_name` key would also be included in the dictionary returned by `asdict`.
### Custom constructor methods
When a component has many properties, it can be a pain to pass each property to the constructor individually.
This is especially true when the component is used in many places and the data preparation would need to be repeated in each use case.
Custom constructor methods can help with that.
In case of our `WelcomePanel` example, we might want to show some more user information, including a profile image and link to the user's profile page.
We can add a `classmethod` that takes the user object and returns an instance of the component with all the data needed to render the component.
We can also use this method to encapsulate the logic for generating additional data, such as the profile URL.
```python
# my_app/components.py
from django import urls
from dataclasses import dataclass, asdict
from laces.components import Component
@dataclass
class WelcomePanel(Component):
template_name = "my_app/components/welcome.html"
first_name: str
last_name: str
profile_url: str
profile_image_url: str
@classmethod
def from_user(cls, user):
profile_url = urls.reverse("profile", kwargs={"pk": user.pk})
return cls(
first_name=user.first_name,
last_name=user.last_name,
profile_url=profile_url,
profile_image_url=user.profile.image.url,
)
def get_context_data(self, parent_context):
return asdict(self)
```
Now, we can instantiate the component in the view like so:
```python
# my_app/views.py
from django.shortcuts import render
from my_app.components import WelcomePanel
def home(request):
welcome = WelcomePanel.from_user(request.user)
return render(
request,
"my_app/home.html",
{"welcome": welcome},
)
```
The constructor method allows us to keep our view very simple and clean as all the data preparation is encapsulated in the component.
As in the example above, custom constructor methods pair very well with the use of dataclasses, but they can of course also be used without them.
## About Laces and components
### Why "Laces"?
"Laces" is somewhat of a reference to the feature of tying data and templates together.
The components are also "self-rendering," which could be seen as "self-reliance," which relates to "bootstrapping."
And aren't "bootstraps" just a long kind of "(shoe)laces?"
Finally, it is a nod to [@mixxorz](https://github.com/mixxorz)'s fantastic [Slippers package](https://github.com/mixxorz/slippers), which also takes a component focused approach to improve the experience when working with Django templates, but in a quite different way.
### Supported versions
- Python >= 3.8
- Django >= 3.2
## Contributing

@@ -258,3 +779,3 @@

With your preferred virtualenv activated, install testing dependencies:
With your preferred virtualenv activated, install the development dependencies:

@@ -265,3 +786,3 @@ #### Using pip

$ python -m pip install --upgrade pip>=21.3
$ python -m pip install -e '.[testing]' -U
$ python -m pip install -e '.[dev]' -U
```

@@ -282,4 +803,2 @@

```shell
# go to the project directory
$ cd laces
# initialize pre-commit

@@ -303,3 +822,3 @@ $ pre-commit install

```sh
$ tox -e python3.11-django4.2-wagtail5.1
$ tox -e python3.11-django4.2
```

@@ -310,3 +829,3 @@

```sh
$ tox -e python3.11-django4.2-wagtail5.1-sqlite laces.tests.test_file.TestClass.test_method
$ tox -e python3.11-django4.2 laces.tests.test_file.TestClass.test_method
```

@@ -322,8 +841,48 @@

#### Testing with coverage
`tox` is configured to run tests with coverage.
The coverage report is combined for all environments.
This is done by using the `--append` flag when running coverage in `tox`.
This means it will also include previous results.
You can see the coverage report by running:
```sh
$ coverage report
```
To get a clean report, you can run `coverage erase` before running `tox`.
#### Running tests without `tox`
If you want to run tests without `tox`, you can use the `testmanage.py` script.
This script is a wrapper around Django's `manage.py` and will run tests with the correct settings.
To make this work, you need to have the `testing` dependencies installed.
```sh
$ python -m pip install -e '.[testing]' -U
```
Then you can run tests with:
```sh
$ ./testmanage.py test
````
To run tests with coverage, use:
```sh
$ coverage run ./testmanage.py test
```
### Python version management
Tox will attempt to find installed Python versions on your machine.
If you use `pyenv` to manage multiple versions, you can tell `tox` to use those versions.
This working, is depended on [`virtualenv-pyenv`](https://pypi.org/project/virtualenv-pyenv/) (note: this is not `pyenv-virtualenv`) which is part of the CI dependencies (just like `tox` itself is).
To enable the use, you want to set the environment variable `VIRTUALENV_DISCOVERY=pyenv`.
To ensure that `tox` will find Python versions installed with `pyenv` you need [`virtualenv-pyenv`](https://pypi.org/project/virtualenv-pyenv/) (note: this is not `pyenv-virtualenv`).
`virtualenv-pyenv` is part of the development dependencies (just like `tox` itself).
Additionally, you have to set the environment variable `VIRTUALENV_DISCOVERY=pyenv`.

@@ -335,4 +894,11 @@ ### Publishing

Before publishing a new release, make sure to update the changelog in `CHANGELOG.md` and the version number in `laces/__init__.py`.
To create a release, you need a Git tag.
The tag can either be created on the command line and pushed or in the "create release" interface on GitHub.
The tag name should be the version number prefixed with a `v` (e.g. `v0.1.0`).
Before publishing a new release, make sure to update
- the changelog in `CHANGELOG.md`, and
- the version number in `laces/__init__.py`.
To manually test publishing the package, you can use `flit`.

@@ -339,0 +905,0 @@ Be sure to configure the `testpypi` repository in your `~/.pypirc` file according to the Flit [documentation](https://flit.pypa.io/en/stable/upload.html#controlling-package-uploads).