Initial commit
This commit is contained in:
2
env/lib/python3.10/site-packages/laces/__init__.py
vendored
Normal file
2
env/lib/python3.10/site-packages/laces/__init__.py
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
VERSION = (0, 1, 1)
|
||||
__version__ = ".".join(map(str, VERSION))
|
||||
BIN
env/lib/python3.10/site-packages/laces/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/laces/__pycache__/apps.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/__pycache__/apps.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/laces/__pycache__/components.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/__pycache__/components.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/laces/__pycache__/typing.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/__pycache__/typing.cpython-310.pyc
vendored
Normal file
Binary file not shown.
7
env/lib/python3.10/site-packages/laces/apps.py
vendored
Normal file
7
env/lib/python3.10/site-packages/laces/apps.py
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LacesAppConfig(AppConfig):
|
||||
label = "laces"
|
||||
name = "laces"
|
||||
verbose_name = "Laces"
|
||||
106
env/lib/python3.10/site-packages/laces/components.py
vendored
Normal file
106
env/lib/python3.10/site-packages/laces/components.py
vendored
Normal 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
|
||||
0
env/lib/python3.10/site-packages/laces/migrations/__init__.py
vendored
Normal file
0
env/lib/python3.10/site-packages/laces/migrations/__init__.py
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/migrations/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/migrations/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
0
env/lib/python3.10/site-packages/laces/templates/.gitkeep
vendored
Normal file
0
env/lib/python3.10/site-packages/laces/templates/.gitkeep
vendored
Normal file
0
env/lib/python3.10/site-packages/laces/templatetags/__init__.py
vendored
Normal file
0
env/lib/python3.10/site-packages/laces/templatetags/__init__.py
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/templatetags/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/templatetags/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/laces/templatetags/__pycache__/laces.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/templatetags/__pycache__/laces.cpython-310.pyc
vendored
Normal file
Binary file not shown.
149
env/lib/python3.10/site-packages/laces/templatetags/laces.py
vendored
Normal file
149
env/lib/python3.10/site-packages/laces/templatetags/laces.py
vendored
Normal 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,
|
||||
)
|
||||
0
env/lib/python3.10/site-packages/laces/tests/__init__.py
vendored
Normal file
0
env/lib/python3.10/site-packages/laces/tests/__init__.py
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/tests/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/tests/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/laces/tests/__pycache__/test_components.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/tests/__pycache__/test_components.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.10/site-packages/laces/tests/__pycache__/utils.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/tests/__pycache__/utils.cpython-310.pyc
vendored
Normal file
Binary file not shown.
284
env/lib/python3.10/site-packages/laces/tests/test_components.py
vendored
Normal file
284
env/lib/python3.10/site-packages/laces/tests/test_components.py
vendored
Normal 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"],
|
||||
),
|
||||
)
|
||||
0
env/lib/python3.10/site-packages/laces/tests/test_templatetags/__init__.py
vendored
Normal file
0
env/lib/python3.10/site-packages/laces/tests/test_templatetags/__init__.py
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/tests/test_templatetags/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
env/lib/python3.10/site-packages/laces/tests/test_templatetags/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
Binary file not shown.
441
env/lib/python3.10/site-packages/laces/tests/test_templatetags/test_laces.py
vendored
Normal file
441
env/lib/python3.10/site-packages/laces/tests/test_templatetags/test_laces.py
vendored
Normal 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'm running with scissors! 8< 8< 8<",
|
||||
)
|
||||
|
||||
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'",
|
||||
)
|
||||
29
env/lib/python3.10/site-packages/laces/tests/utils.py
vendored
Normal file
29
env/lib/python3.10/site-packages/laces/tests/utils.py
vendored
Normal 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)
|
||||
32
env/lib/python3.10/site-packages/laces/typing.py
vendored
Normal file
32
env/lib/python3.10/site-packages/laces/typing.py
vendored
Normal 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
|
||||
Reference in New Issue
Block a user