Initial commit

This commit is contained in:
2024-08-27 20:33:44 +02:00
commit 1f1832267d
14794 changed files with 1599592 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
VERSION = (0, 1, 1)
__version__ = ".".join(map(str, VERSION))

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class LacesAppConfig(AppConfig):
label = "laces"
name = "laces"
verbose_name = "Laces"

View File

@@ -0,0 +1,106 @@
from typing import TYPE_CHECKING, List
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):
"""
A class that knows how to render itself.
Extracted from Wagtail. See:
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/
"""
template_name: str
def render_html(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "SafeString":
"""
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),
returns the string representation to be rendered.
This will be subject to Django's HTML escaping rules, so a return value
consisting of HTML should typically be returned as a
`django.utils.safestring.SafeString` instance.
"""
if parent_context is None:
parent_context = Context()
context_data = self.get_context_data(parent_context)
if context_data is None:
raise TypeError("Expected a dict from get_context_data, got None")
template = get_template(self.template_name)
return template.render(context_data)
def get_context_data(
self,
parent_context: "RenderContext",
) -> "Optional[RenderContext]":
return {}
# 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
of its members.
Extracted from Wagtail. See:
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) -> 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()
for item in self:
media += item.media
return media

View File

@@ -0,0 +1,149 @@
from typing import TYPE_CHECKING
from django import template
from django.template.base import token_kwargs
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()
class ComponentNode(template.Node):
"""
Template node to render a component.
Extracted from Wagtail. See:
https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/templatetags/wagtailadmin_tags.py#L937-L987 # noqa: E501
"""
def __init__(
self,
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
self.extra_context = extra_context or {}
self.isolated_context = isolated_context
self.fallback_render_method = fallback_render_method
self.target_var = target_var
def render(self, context: template.Context) -> SafeString:
"""
Render the ComponentNode template node.
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:
fallback_render_method = self.fallback_render_method.resolve(context)
else:
fallback_render_method = False
values = {
name: var.resolve(context) for name, var in self.extra_context.items()
}
if hasattr(component, "render_html"):
if self.isolated_context:
html = component.render_html(context.new(values))
else:
with context.push(**values):
html = component.render_html(context)
elif fallback_render_method and hasattr(component, "render"):
html = component.render()
else:
raise ValueError(f"Cannot render {component!r} as a component")
if self.target_var:
context[self.target_var] = html
return SafeString("")
else:
if context.autoescape:
html = conditional_escape(html)
return html
@register.tag(name="component")
def component(parser: "Parser", token: "Token") -> ComponentNode:
"""
Template tag to render a component via ComponentNode.
Extracted from Wagtail. See:
https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/templatetags/wagtailadmin_tags.py#L990-L1037 # noqa: E501
"""
bits = token.split_contents()[1:]
if not bits:
raise template.TemplateSyntaxError(
"'component' tag requires at least one argument, the component object"
)
component = parser.compile_filter(bits.pop(0))
# the only valid keyword argument immediately following the component
# is fallback_render_method
kwargs = token_kwargs(bits, parser)
fallback_render_method = kwargs.pop("fallback_render_method", None)
if kwargs:
raise template.TemplateSyntaxError(
"'component' tag only accepts 'fallback_render_method' as a keyword argument"
)
extra_context = {}
isolated_context = False
target_var = None
while bits:
bit = bits.pop(0)
if bit == "with":
extra_context = token_kwargs(bits, parser)
elif bit == "only":
isolated_context = True
elif bit == "as":
try:
target_var = bits.pop(0)
except IndexError:
raise template.TemplateSyntaxError(
"'component' tag with 'as' must be followed by a variable name"
)
else:
raise template.TemplateSyntaxError(
"'component' tag received an unknown argument: %r" % bit
)
return ComponentNode(
component,
extra_context=extra_context,
isolated_context=isolated_context,
fallback_render_method=fallback_render_method,
target_var=target_var,
)

View File

@@ -0,0 +1,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"],
),
)

View File

@@ -0,0 +1,441 @@
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
from django.utils.html import format_html
from laces.components import Component
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):
"""
Test for the `component` template tag.
Extracted from Wagtail. See:
https://github.com/wagtail/wagtail/blob/main/wagtail/admin/tests/test_templatetags.py#L225-L305 # noqa: E501
"""
def setUp(self) -> None:
self.parent_template = Template("")
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},
)
# 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<"
)
self.set_parent_template("{% component my_component %}")
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
self.assertEqual(
result,
"Look, I&#x27;m running with scissors! 8&lt; 8&lt; 8&lt;",
)
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 %}")
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):
self.render_parent_template_with_context(
{"my_non_component": non_component},
)
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",
)
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 }}"
)
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(
str(cm.exception),
"'component' tag with 'as' must be followed by a variable name",
)
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 %}"
)
result = self.render_parent_template_with_context(
{"my_component": self.component},
)
self.assertEqual(
result,
"Look, I'm running with scissors! 8< 8< 8<",
)
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",
)
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'",
)

View File

@@ -0,0 +1,29 @@
"""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)

View File

@@ -0,0 +1,32 @@
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