diff --git a/.coveragerc b/.coveragerc index 4e1ef99..03e0b31 100644 --- a/.coveragerc +++ b/.coveragerc @@ -22,3 +22,4 @@ exclude_lines = if __name__ == .__main__.: ignore_errors = True +show_missing = True diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4404f25..f343ce3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,3 +49,9 @@ jobs: run: tox env: DB: sqlite + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: ./.coverage.json diff --git a/.gitignore b/.gitignore index 4060bfb..ba4a900 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ /dist /laces.egg-info /.coverage +/.coverage.json /htmlcov /.tox /.venv diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cff896..ae4d1f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- ... +- Add more tests and example usage. ### Changed diff --git a/README.md b/README.md index 48c7ebf..db24ffa 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD--3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![PyPI version](https://badge.fury.io/py/laces.svg)](https://badge.fury.io/py/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) --- @@ -50,8 +51,7 @@ That's it. ### 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. ```python # my_app/components.py @@ -60,19 +60,54 @@ from laces.components import Component 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 #} +

Welcome to my app!

+``` + +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 -my_welcome_panel = WelcomePanel() +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 #} -

Welcome to my app!

+{% load laces %} +{% component welcome %} ``` -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 # my_app/components.py @@ -82,11 +117,11 @@ from laces.components import Component class WelcomePanel(Component): - def render_html(self, parent_context): - return format_html("

{}

", "Welcome to my app!") + def render_html(self, parent_context=None): + return format_html("

Welcome to my app!

") ``` -### 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. @@ -98,7 +133,7 @@ from laces.components import Component 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) @@ -107,7 +142,7 @@ class WelcomePanel(Component): ``` ```html+django -{# my_app/templates/my_app/panels/welcome.html #} +{# my_app/templates/my_app/components/welcome.html #}

Welcome to my app, {{ username }}!

``` @@ -123,7 +158,7 @@ from laces.components import Component 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",)} @@ -134,7 +169,7 @@ class WelcomePanel(Component): 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). -For example, given the view passes an instance of `WelcomePanel` to the context of `my_app/welcome.html`. +For example, given the view passes an instance of `WelcomePanel` to the context of `my_app/home.html`. ```python # my_app/views.py @@ -144,45 +179,45 @@ from django.shortcuts import render from my_app.components import WelcomePanel -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, }, ) ``` -The template `my_app/templates/my_app/welcome.html` could render the panel as follows: +The template `my_app/templates/my_app/home.html` could 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`: ```html+django -{% component panel with username=request.user.username %} +{% component welcome with username=request.user.username %} ``` To render the component with only the variables provided (and no others from the calling template's context), use `only`: ```html+django -{% component panel with username=request.user.username only %} +{% component welcome with username=request.user.username only %} ``` 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. @@ -197,20 +232,20 @@ from django.shortcuts import render from my_app.components import WelcomePanel -def welcome_page(request): - panels = [ +def home(request): + components = [ WelcomePanel(), ] media = Media() - for panel in panels: - media += panel.media + for component in components: + media += component.media render( request, - "my_app/welcome.html", + "my_app/home.html", { - "panels": panels, + "components": components, "media": media, }, ) @@ -218,7 +253,7 @@ def welcome_page(request): ```html+django -{# my_app/templates/my_app/welcome.html #} +{# my_app/templates/my_app/home.html #} {% load laces %} @@ -227,8 +262,8 @@ def welcome_page(request): {{ media.css }} - {% for panel in panels %} - {% component panel %} + {% for comp in components %} + {% component comp %} {% endfor %} ``` @@ -303,6 +338,25 @@ $ tox -e interactive You can now visit `http://localhost:8020/`. +#### Testing with coverage + +To run tests with coverage, use: + +```sh +$ coverage run ./testmanage.py test +``` + +Then see the results with + +```sh +$ coverage report +``` + +When the tests are run with `tox`, 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. +To get a clean report, you can run `coverage erase` before running `tox`. + ### Python version management Tox will attempt to find installed Python versions on your machine. diff --git a/laces/components.py b/laces/components.py index 46fbadf..ba8f545 100644 --- a/laces/components.py +++ b/laces/components.py @@ -11,19 +11,19 @@ class Component(metaclass=MediaDefiningClass): Extracted from Wagtail. See: https://github.com/wagtail/wagtail/blob/094834909d5c4b48517fd2703eb1f6d386572ffa/wagtail/admin/ui/components.py#L8-L22 # noqa: E501 - """ - def get_context_data( - self, parent_context: MutableMapping[str, Any] - ) -> MutableMapping[str, Any]: - return {} + 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 render_html(self, parent_context: MutableMapping[str, Any] = None) -> str: """ 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. This will be subject to Django's HTML escaping rules, so a return value @@ -39,18 +39,39 @@ def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str: template = get_template(self.template_name) return template.render(context_data) + def get_context_data( + self, parent_context: MutableMapping[str, Any] + ) -> MutableMapping[str, Any]: + return {} + class MediaContainer(list): """ - 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. 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): + """ + 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 diff --git a/laces/models.py b/laces/templates/.gitkeep similarity index 100% rename from laces/models.py rename to laces/templates/.gitkeep diff --git a/laces/templatetags/laces.py b/laces/templatetags/laces.py index 761960f..5c7c864 100644 --- a/laces/templatetags/laces.py +++ b/laces/templatetags/laces.py @@ -29,12 +29,28 @@ def __init__( self.target_var = target_var 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. + """ + 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 = self.component.resolve(context) if self.fallback_render_method: @@ -84,9 +100,9 @@ def component(parser, token): # the only valid keyword argument immediately following the component # 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( "'component' tag only accepts 'fallback_render_method' as a keyword argument" ) diff --git a/laces/test/components/__init__.py b/laces/test/components/__init__.py deleted file mode 100644 index 45f4f4c..0000000 --- a/laces/test/components/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .heading import * # noqa: F401, F403 diff --git a/laces/test/components/heading.py b/laces/test/components/heading.py deleted file mode 100644 index 23f2557..0000000 --- a/laces/test/components/heading.py +++ /dev/null @@ -1,5 +0,0 @@ -from laces.components import Component - - -class Heading(Component): - template_name = "components/heading.html" diff --git a/laces/test/components/templates/components/heading.html b/laces/test/components/templates/components/heading.html deleted file mode 100644 index e583759..0000000 --- a/laces/test/components/templates/components/heading.html +++ /dev/null @@ -1 +0,0 @@ -

Test

diff --git a/laces/test/home/__init__.py b/laces/test/example/__init__.py similarity index 100% rename from laces/test/home/__init__.py rename to laces/test/example/__init__.py diff --git a/laces/test/example/components.py b/laces/test/example/components.py new file mode 100644 index 0000000..43ea303 --- /dev/null +++ b/laces/test/example/components.py @@ -0,0 +1,123 @@ +""" +Examples of how components might be defined. + +This is unlikely to be an exhaustive list of examples, but it should be enough to +demonstrate the basic concepts of how components work. +""" +from dataclasses import asdict, dataclass + +from django.utils.html import format_html + +from laces.components import Component + + +class RendersTemplateWithFixedContentComponent(Component): + template_name = "components/hello-world.html" + + +class ReturnsFixedContentComponent(Component): + def render_html(self, parent_context=None): + return format_html("

Hello World Return

\n") + + +class PassesFixedNameToContextComponent(Component): + template_name = "components/hello-name.html" + + def get_context_data(self, parent_context=None): + return {"name": "Alice"} + + +class PassesInstanceAttributeToContextComponent(Component): + template_name = "components/hello-name.html" + + def __init__(self, name, **kwargs): + super().__init__(**kwargs) + self.name = name + + def get_context_data(self, parent_context=None): + return {"name": self.name} + + +class PassesSelfToContextComponent(Component): + template_name = "components/hello-self-name.html" + + def __init__(self, name, **kwargs): + super().__init__(**kwargs) + self.name = name + + def get_context_data(self, parent_context=None): + return {"self": self} + + +@dataclass +class DataclassAsDictContextComponent(Component): + template_name = "components/hello-name.html" + + name: str + + def get_context_data(self, parent_context=None): + return asdict(self) + + +class PassesNameFromParentContextComponent(Component): + template_name = "components/hello-name.html" + + def get_context_data(self, parent_context): + return {"name": parent_context["name"]} + + +class SectionWithHeadingAndParagraphComponent(Component): + template_name = "components/section.html" + + def __init__(self, heading: "HeadingComponent", content: "ParagraphComponent"): + super().__init__() + self.heading = heading + self.content = content + + def get_context_data(self, parent_context=None): + return { + "heading": self.heading, + "content": self.content, + } + + +class HeadingComponent(Component): + def __init__(self, text: str): + super().__init__() + self.text = text + + def render_html(self, parent_context=None): + return format_html("

{}

\n", self.text) + + +class ParagraphComponent(Component): + def __init__(self, text: str): + super().__init__() + self.text = text + + def render_html(self, parent_context=None): + return format_html("

{}

\n", self.text) + + +class ListSectionComponent(Component): + template_name = "components/list-section.html" + + def __init__(self, heading: "HeadingComponent", items: "list[Component]"): + super().__init__() + self.heading = heading + self.items = items + + def get_context_data(self, parent_context=None): + return { + "heading": self.heading, + "items": self.items, + } + + +class BlockquoteComponent(Component): + def __init__(self, text: str): + super().__init__() + self.text = text + + def render_html(self, parent_context=None): + return format_html("
{}
\n", self.text) diff --git a/laces/test/example/templates/components/hello-name.html b/laces/test/example/templates/components/hello-name.html new file mode 100644 index 0000000..71499e2 --- /dev/null +++ b/laces/test/example/templates/components/hello-name.html @@ -0,0 +1 @@ +

Hello {{ name }}

diff --git a/laces/test/example/templates/components/hello-self-name.html b/laces/test/example/templates/components/hello-self-name.html new file mode 100644 index 0000000..4bc30e3 --- /dev/null +++ b/laces/test/example/templates/components/hello-self-name.html @@ -0,0 +1 @@ +

Hello {{ self.name }}'s self

diff --git a/laces/test/example/templates/components/hello-world.html b/laces/test/example/templates/components/hello-world.html new file mode 100644 index 0000000..f3e333e --- /dev/null +++ b/laces/test/example/templates/components/hello-world.html @@ -0,0 +1 @@ +

Hello World

diff --git a/laces/test/example/templates/components/list-section.html b/laces/test/example/templates/components/list-section.html new file mode 100644 index 0000000..e48382a --- /dev/null +++ b/laces/test/example/templates/components/list-section.html @@ -0,0 +1,9 @@ +{% load laces %} +
+ {% component heading %} + +
diff --git a/laces/test/example/templates/components/section.html b/laces/test/example/templates/components/section.html new file mode 100644 index 0000000..641fc96 --- /dev/null +++ b/laces/test/example/templates/components/section.html @@ -0,0 +1,5 @@ +{% load laces %} +
+ {% component heading %} + {% component content %} +
diff --git a/laces/test/example/templates/pages/kitchen-sink.html b/laces/test/example/templates/pages/kitchen-sink.html new file mode 100644 index 0000000..a880c25 --- /dev/null +++ b/laces/test/example/templates/pages/kitchen-sink.html @@ -0,0 +1,24 @@ +{% load laces %} + + + + Kitchen Sink + + + {% component fixed_content_template %} + {% component fixed_content_return %} + {% component passes_fixed_name %} + {% component passes_instance_attr_name %} + {% component passes_self %} + {% component dataclass_attr_name %} + {% component passes_name_from_parent_context %} + {% with name="Erin" %} + {% comment %} + Override parent context through the with-block. The same component receives a different parent context. + {% endcomment %} + {% component passes_name_from_parent_context %} + {% endwith %} + {% component section_with_heading_and_paragraph %} + {% component list_section %} + + diff --git a/laces/test/example/views.py b/laces/test/example/views.py new file mode 100644 index 0000000..d295c15 --- /dev/null +++ b/laces/test/example/views.py @@ -0,0 +1,56 @@ +from django.shortcuts import render + +from laces.test.example.components import ( + BlockquoteComponent, + DataclassAsDictContextComponent, + HeadingComponent, + ListSectionComponent, + ParagraphComponent, + PassesFixedNameToContextComponent, + PassesInstanceAttributeToContextComponent, + PassesNameFromParentContextComponent, + PassesSelfToContextComponent, + RendersTemplateWithFixedContentComponent, + ReturnsFixedContentComponent, + SectionWithHeadingAndParagraphComponent, +) + + +def kitchen_sink(request): + """Render a page with all example components.""" + fixed_content_template = RendersTemplateWithFixedContentComponent() + fixed_content_return = ReturnsFixedContentComponent() + passes_fixed_name = PassesFixedNameToContextComponent() + passes_instance_attr_name = PassesInstanceAttributeToContextComponent(name="Bob") + passes_self = PassesSelfToContextComponent(name="Carol") + dataclass_attr_name = DataclassAsDictContextComponent(name="Charlie") + passes_name_from_parent_context = PassesNameFromParentContextComponent() + section_with_heading_and_paragraph = SectionWithHeadingAndParagraphComponent( + heading=HeadingComponent(text="Hello"), + content=ParagraphComponent(text="World"), + ) + list_section = ListSectionComponent( + heading=HeadingComponent(text="Heading"), + items=[ + ParagraphComponent(text="Item 1"), + BlockquoteComponent(text="Item 2"), + ParagraphComponent(text="Item 3"), + ], + ) + + return render( + request, + template_name="pages/kitchen-sink.html", + context={ + "fixed_content_template": fixed_content_template, + "fixed_content_return": fixed_content_return, + "passes_fixed_name": passes_fixed_name, + "passes_instance_attr_name": passes_instance_attr_name, + "passes_self": passes_self, + "dataclass_attr_name": dataclass_attr_name, + "passes_name_from_parent_context": passes_name_from_parent_context, + "name": "Dan", # Provide as an example of parent context. + "section_with_heading_and_paragraph": section_with_heading_and_paragraph, + "list_section": list_section, + }, + ) diff --git a/laces/test/home/templates/home/home.html b/laces/test/home/templates/home/home.html deleted file mode 100644 index 1a00229..0000000 --- a/laces/test/home/templates/home/home.html +++ /dev/null @@ -1,2 +0,0 @@ -{% load laces %} -{% component heading %} diff --git a/laces/test/home/views.py b/laces/test/home/views.py deleted file mode 100644 index ea1f6fe..0000000 --- a/laces/test/home/views.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.shortcuts import render - -from laces.test.components import Heading - - -def home(request): - heading = Heading() - return render( - request, - template_name="home/home.html", - context={"heading": heading}, - ) diff --git a/laces/test/settings.py b/laces/test/settings.py index b2148d7..343cbd3 100644 --- a/laces/test/settings.py +++ b/laces/test/settings.py @@ -35,8 +35,7 @@ INSTALLED_APPS = [ "laces", "laces.test", - "laces.test.home", - "laces.test.components", + "laces.test.example", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", diff --git a/laces/test/tests/test_components.py b/laces/test/tests/test_components.py new file mode 100644 index 0000000..ccb041b --- /dev/null +++ b/laces/test/tests/test_components.py @@ -0,0 +1,251 @@ +""" +Tests for the example components. + +These tests are very basic and only ensure that the examples are configured as +desired. More thorough tests can be found in the `laces.tests.test_components` module. +""" +from django.test import SimpleTestCase + +from laces.test.example.components import ( + DataclassAsDictContextComponent, + HeadingComponent, + ListSectionComponent, + ParagraphComponent, + PassesFixedNameToContextComponent, + PassesInstanceAttributeToContextComponent, + PassesNameFromParentContextComponent, + PassesSelfToContextComponent, + RendersTemplateWithFixedContentComponent, + ReturnsFixedContentComponent, + SectionWithHeadingAndParagraphComponent, +) + + +class TestRendersTemplateWithFixedContentComponent(SimpleTestCase): + def setUp(self): + self.component = RendersTemplateWithFixedContentComponent() + + def test_template_name(self): + self.assertEqual( + self.component.template_name, + "components/hello-world.html", + ) + + def test_render_html(self): + self.assertEqual( + self.component.render_html(), + "

Hello World

\n", + ) + + +class TestReturnsFixedContentComponent(SimpleTestCase): + def setUp(self): + self.component = ReturnsFixedContentComponent() + + def test_template_name(self): + self.assertFalse(hasattr(self.component, "template_name")) + + def test_render_html(self): + self.assertEqual( + self.component.render_html(), + "

Hello World Return

\n", + ) + + +class TestPassesFixedNameToContextComponent(SimpleTestCase): + def setUp(self): + self.component = PassesFixedNameToContextComponent() + + def test_template_name(self): + self.assertEqual( + self.component.template_name, + "components/hello-name.html", + ) + + def test_get_context_data(self): + self.assertEqual( + self.component.get_context_data(), + {"name": "Alice"}, + ) + + def test_render_html(self): + self.assertEqual( + self.component.render_html(), + "

Hello Alice

\n", + ) + + +class TestPassesInstanceAttributeToContextComponent(SimpleTestCase): + def setUp(self): + self.component = PassesInstanceAttributeToContextComponent(name="Bob") + + def test_template_name(self): + self.assertEqual( + self.component.template_name, + "components/hello-name.html", + ) + + def test_get_context_data(self): + self.assertEqual( + self.component.name, + "Bob", + ) + self.assertEqual( + self.component.get_context_data(), + {"name": "Bob"}, + ) + + def test_render_html(self): + self.assertEqual( + self.component.render_html(), + "

Hello Bob

\n", + ) + + +class TestPassesSelfToContextComponent(SimpleTestCase): + def setUp(self): + self.component = PassesSelfToContextComponent(name="Carol") + + def test_template_name(self): + self.assertEqual( + self.component.template_name, + "components/hello-self-name.html", + ) + + def test_get_context_data(self): + self.assertEqual( + self.component.get_context_data(), + {"self": self.component}, + ) + + def test_render_html(self): + self.assertEqual( + self.component.render_html(), + "

Hello Carol's self

\n", + ) + + +class TestDataclassAsDictContextComponent(SimpleTestCase): + def setUp(self): + self.component = DataclassAsDictContextComponent(name="Charlie") + + def test_template_name(self): + self.assertEqual( + self.component.template_name, + "components/hello-name.html", + ) + + def test_get_context_data(self): + self.assertEqual( + self.component.name, + "Charlie", + ) + self.assertEqual( + self.component.get_context_data(), + {"name": "Charlie"}, + ) + + def test_render_html(self): + self.assertEqual( + self.component.render_html(), + "

Hello Charlie

\n", + ) + + +class TestPassesNameFromParentContextComponent(SimpleTestCase): + def setUp(self): + self.component = PassesNameFromParentContextComponent() + + def test_template_name(self): + self.assertEqual( + self.component.template_name, + "components/hello-name.html", + ) + + def test_get_context_data(self): + self.assertEqual( + self.component.get_context_data(parent_context={"name": "Dan"}), + {"name": "Dan"}, + ) + + def test_render_html(self): + self.assertEqual( + self.component.render_html(parent_context={"name": "Dan"}), + "

Hello Dan

\n", + ) + + +class TestSectionWithHeadingAndParagraphComponent(SimpleTestCase): + def setUp(self): + self.heading = HeadingComponent(text="Heading") + self.content = ParagraphComponent(text="Paragraph") + self.component = SectionWithHeadingAndParagraphComponent( + heading=self.heading, + content=self.content, + ) + + def test_template_name(self): + self.assertEqual( + self.component.template_name, + "components/section.html", + ) + + def test_get_context_data(self): + self.assertEqual( + self.component.get_context_data(), + { + "heading": self.heading, + "content": self.content, + }, + ) + + def test_render_html(self): + self.assertHTMLEqual( + self.component.render_html(), + """ +
+

Heading

+

Paragraph

+
+ """, + ) + + +class TestListSection(SimpleTestCase): + def setUp(self): + self.heading = HeadingComponent(text="Heading") + self.item = ParagraphComponent(text="Paragraph") + self.component = ListSectionComponent( + heading=self.heading, + items=[self.item], + ) + + def test_template_name(self): + self.assertEqual( + self.component.template_name, + "components/list-section.html", + ) + + def test_get_context_data(self): + self.assertEqual( + self.component.get_context_data(), + { + "heading": self.heading, + "items": [self.item], + }, + ) + + def test_render_html(self): + self.assertHTMLEqual( + self.component.render_html(), + """ +
+

Heading

+ +
+ """, + ) diff --git a/laces/test/tests/test_views.py b/laces/test/tests/test_views.py new file mode 100644 index 0000000..9adeeb4 --- /dev/null +++ b/laces/test/tests/test_views.py @@ -0,0 +1,53 @@ +"""Tests for the example views that demonstrate the use of components.""" +from http import HTTPStatus + +from django.test import RequestFactory, TestCase + +from laces.test.example.views import kitchen_sink + + +class TestKitchenSink(TestCase): + def test_get(self): + factory = RequestFactory() + request = factory.get("/") + + response = kitchen_sink(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + response_html = response.content.decode("utf-8") + self.assertInHTML("

Hello World

", response_html) + self.assertInHTML("

Hello World Return

", response_html) + self.assertInHTML("

Hello Alice

", response_html) + self.assertInHTML("

Hello Bob

", response_html) + self.assertInHTML("

Hello Carol's self

", response_html) + self.assertInHTML("

Hello Charlie

", response_html) + self.assertInHTML("

Hello Dan

", response_html) + self.assertInHTML("

Hello Erin

", response_html) + self.assertInHTML( + """ +
+

Hello

+

World

+
+ """, + response_html, + ) + self.assertInHTML( + """ +
+

Heading

+ +
+ """, + response_html, + ) diff --git a/laces/test/urls.py b/laces/test/urls.py index f586e93..85bfc8f 100644 --- a/laces/test/urls.py +++ b/laces/test/urls.py @@ -1,10 +1,8 @@ -from django.contrib import admin from django.urls import path -from laces.test.home.views import home +from laces.test.example.views import kitchen_sink urlpatterns = [ - path("", home), - path("django-admin/", admin.site.urls), + path("", kitchen_sink), ] diff --git a/laces/tests/test_components.py b/laces/tests/test_components.py new file mode 100644 index 0000000..0fd10cc --- /dev/null +++ b/laces/tests/test_components.py @@ -0,0 +1,270 @@ +import os +import random + +from pathlib import Path + +from django.conf import settings +from django.forms.widgets import Media +from django.template import Context +from django.test import SimpleTestCase +from django.utils.html import SafeString + +from laces.components import Component, MediaContainer + + +class TestComponent(SimpleTestCase): + """Directly test the Component class.""" + + def setUp(self): + self.component = Component() + + def test_render_html(self): + """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): + """ + 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): + """ + Test the `media` property. + + The `media` property is added through the `metaclass=MediaDefiningClass` + definition. + + """ + self.assertIsInstance(self.component.media, Media) + empty_media = Media() + # We need to compare the internal dicts and lists as the `Media` class does not + # implement `__eq__`. + self.assertEqual(self.component.media._css, empty_media._css) + self.assertEqual(self.component.media._js, empty_media._js) + + +class TestComponentSubclasses(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): + return f"example-{random.randint(1000, 10000)}.html" + + @classmethod + def get_example_template_name(cls): + 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): + 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): + with open(self.example_template, "w") as f: + f.write(content) + + def test_render_html_with_template_name_set(self): + """ + 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): + """ + 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): + 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): + """ + 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` the provided context. + """ + + # ----------------------------------------------------------------------------- + class ExampleComponent(Component): + def get_context_data(self, parent_context): + return None + + # ----------------------------------------------------------------------------- + + with self.assertRaises(TypeError): + ExampleComponent().render_html() + + def test_media_defined_through_nested_class(self): + """ + 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, Media) + self.assertEqual(result._css, {"all": ["example.css"]}) + self.assertEqual(result._js, ["example.js"]) + + def tearDown(self): + os.remove(path=self.example_template) + + +class TestMediaContainer(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): + self.media_container = MediaContainer() + + def test_empty(self): + result = self.media_container.media + + self.assertIsInstance(result, Media) + self.assertEqual(result._css, {}) + self.assertEqual(result._js, []) + + def test_single_member(self): + # ----------------------------------------------------------------------------- + class ExampleClass: + media = Media(css={"all": ["example.css"]}) + + # ----------------------------------------------------------------------------- + example = ExampleClass() + self.media_container.append(example) + + result = self.media_container.media + + self.assertIsInstance(result, Media) + self.assertEqual(result._css, example.media._css) + self.assertEqual(result._css, {"all": ["example.css"]}) + self.assertEqual(result._js, example.media._js) + self.assertEqual(result._js, []) + + def test_two_members_of_same_class(self): + # ----------------------------------------------------------------------------- + class ExampleClass: + media = 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, Media) + self.assertEqual(result._css, example_1.media._css) + self.assertEqual(result._css, {"all": ["example.css"]}) + self.assertEqual(result._js, example_1.media._js) + self.assertEqual(result._js, ["example.js"]) + + def test_two_members_of_different_classes(self): + # ----------------------------------------------------------------------------- + class ExampleClass: + media = Media(css={"all": ["shared.css"]}, js=["example.js"]) + + class OtherExampleClass: + media = 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, Media) + self.assertEqual( + result._css, + { + "all": ["other.css", "shared.css"], + "print": ["print.css"], + }, + ) + self.assertEqual(result._js, ["example.js", "other.js"]) diff --git a/laces/tests/test_templatetags/test_laces.py b/laces/tests/test_templatetags/test_laces.py index cc29047..46c58f8 100644 --- a/laces/tests/test_templatetags/test_laces.py +++ b/laces/tests/test_templatetags/test_laces.py @@ -1,4 +1,11 @@ -from django.template import Context, Template +import os +import random + +from pathlib import Path +from unittest.mock import Mock + +from django.conf import settings +from django.template import Context, Template, TemplateSyntaxError from django.test import SimpleTestCase from django.utils.html import format_html @@ -13,79 +20,306 @@ class TestComponentTag(SimpleTestCase): https://github.com/wagtail/wagtail/blob/main/wagtail/admin/tests/test_templatetags.py#L225-L305 # noqa: E501 """ - def test_passing_context_to_component(self): - class MyComponent(Component): - def render_html(self, parent_context): - return format_html( - "

{} was here

", parent_context.get("first_name", "nobody") - ) + def setUp(self): + self.parent_template = Template("") + + class ExampleComponent(Component): + pass + + self.component = ExampleComponent() + # Using a mock to be able to check if the `render_html` method is called. + self.component.render_html: Mock = Mock(return_value="Rendered HTML") + + def set_parent_template(self, template_string): + template_string = "{% load laces %}" + template_string + self.parent_template = Template(template_string) + + def render_parent_template_with_context(self, context: dict): + return self.parent_template.render(Context(context)) + + def assertRenderHTMLCalledWith(self, context: dict): + self.assertTrue(self.component.render_html.called_with(Context(context))) + + def test_render_html_return_in_parent_template(self): + 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): + 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): + self.component.render_html.return_value = format_html("

My component

") + self.set_parent_template("{% component my_component %}") + + result = self.render_parent_template_with_context( + {"my_component": self.component}, + ) + + self.assertEqual(result, "

My component

") + + def test_render_html_return_not_escaped_when_actually_rendered_template(self): + 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("

My component

") + + # ----------------------------------------------------------------------------- + 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, "

My component

") + os.remove(example_template) + + def test_render_html_parent_context_when_only_component_in_context(self): + self.set_parent_template("{% component my_component %}") + + self.render_parent_template_with_context({"my_component": self.component}) + + self.assertTrue(self.component.render_html.called) + # The component itself is not included in the context that is passed to the + # `render_html` method. + self.assertRenderHTMLCalledWith({}) + + def test_render_html_parent_context_when_other_variable_in_context(self): + self.set_parent_template("{% component my_component %}") + + self.render_parent_template_with_context( + { + "my_component": self.component, + "test": "something", + } + ) + + self.assertRenderHTMLCalledWith({"test": "something"}) + + def test_render_html_parent_context_when_with_block_sets_extra_context(self): + self.set_parent_template( + "{% with test='something' %}{% component my_component %}{% endwith %}" + ) + + self.render_parent_template_with_context({"my_component": self.component}) + + self.assertRenderHTMLCalledWith({"test": "something"}) + + def test_render_html_parent_context_when_with_keyword_sets_extra_context(self): + self.set_parent_template("{% component my_component with test='something' %}") + + self.render_parent_template_with_context({"my_component": self.component}) + + self.assertRenderHTMLCalledWith({"test": "something"}) + + def test_render_html_parent_context_when_with_only_keyword_limits_extra_context( + self, + ): + 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", + } + ) - template = Template( - "{% load laces %}{% with first_name='Kilroy' %}{% component my_component %}{% endwith %}" + # The `other` variable from the parent's rendering context is 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.assertRenderHTMLCalledWith({"test": "nothing else"}) + + def test_render_html_parent_context_when_with_block_overrides_context(self): + self.set_parent_template( + "{% with test='something else' %}{% component my_component %}{% endwith %}" ) - html = template.render(Context({"my_component": MyComponent()})) - self.assertEqual(html, "

Kilroy was here

") - template = Template( - "{% load laces %}{% component my_component with first_name='Kilroy' %}" + self.render_parent_template_with_context( + { + "my_component": self.component, + "test": "something", + } ) - html = template.render(Context({"my_component": MyComponent()})) - self.assertEqual(html, "

Kilroy was here

") - template = Template( - "{% load laces %}{% with first_name='Kilroy' %}{% component my_component with surname='Silk' only %}{% endwith %}" + self.assertRenderHTMLCalledWith({"test": "something else"}) + + def test_render_html_parent_context_when_with_keyword_overrides_context(self): + self.set_parent_template( + "{% component my_component with test='something else' %}" ) - html = template.render(Context({"my_component": MyComponent()})) - self.assertEqual(html, "

nobody was here

") - def test_fallback_render_method(self): - class MyComponent(Component): - def render_html(self, parent_context): - return format_html("

I am a component

") + self.render_parent_template_with_context( + { + "my_component": self.component, + "test": "something", + } + ) + + self.assertRenderHTMLCalledWith({"test": "something else"}) + + def test_render_html_parent_context_when_with_keyword_overrides_with_block(self): + 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.assertRenderHTMLCalledWith({"test": "something else"}) - class MyNonComponent: + def test_fallback_render_method_arg_true_and_object_with_render_method(self): + # ----------------------------------------------------------------------------- + class ExampleNonComponentWithRenderMethod: def render(self): - return format_html("

I am not a component

") + 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): + # ----------------------------------------------------------------------------- + class ExampleNonComponentWithoutRenderMethod: + pass + + # ----------------------------------------------------------------------------- + non_component = ExampleNonComponentWithoutRenderMethod() + self.set_parent_template( + "{% component my_non_component fallback_render_method=True %}" + ) - template = Template("{% load laces %}{% component my_component %}") - html = template.render(Context({"my_component": MyComponent()})) - self.assertEqual(html, "

I am a component

") with self.assertRaises(ValueError): - template.render(Context({"my_component": MyNonComponent()})) + self.render_parent_template_with_context( + {"my_non_component": non_component}, + ) + + def test_no_fallback_render_method_arg_and_object_without_render_method(self): + # ----------------------------------------------------------------------------- + class ExampleNonComponentWithoutRenderMethod: + def __repr__(self): + return "" + + # ----------------------------------------------------------------------------- + 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 as a component", + ) - template = Template( - "{% load laces %}{% component my_component fallback_render_method=True %}" + def test_as_keyword_stores_render_html_return_as_variable(self): + self.set_parent_template( + "{% component my_component as my_var %}The result was: {{ my_var }}" ) - html = template.render(Context({"my_component": MyComponent()})) - self.assertEqual(html, "

I am a component

") - html = template.render(Context({"my_component": MyNonComponent()})) - self.assertEqual(html, "

I am not a component

") - 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<" + 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): + # 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 %}") - template = Template("{% load laces %}

{% component my_component %}

") - html = template.render(Context({"my_component": MyComponent()})) self.assertEqual( - html, "

Look, I'm running with scissors! 8< 8< 8<

" + 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 %}

{% component my_component %}

") + def test_autoescape_off_block_can_disable_escaping_of_render_html_return(self): + 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}, + ) + + self.assertEqual( + result, + "Look, I'm running with scissors! 8< 8< 8<", + ) + + def test_parsing_no_arguments(self): + # 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_render_as_var(self): - class MyComponent(Component): - def render_html(self, parent_context): - return format_html("

I am a component

") + def test_parsing_unknown_kwarg(self): + # 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 %}") - template = Template( - "{% load laces %}{% component my_component as my_html %}The result was: {{ my_html }}" + self.assertEqual( + str(cm.exception), + "'component' tag only accepts 'fallback_render_method' as a keyword argument", + ) + + def test_parsing_unknown_bit(self): + # 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'", ) - html = template.render(Context({"my_component": MyComponent()})) - self.assertEqual(html, "The result was:

I am a component

") diff --git a/pyproject.toml b/pyproject.toml index 9cf8bb1..e94b429 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,12 +33,14 @@ dependencies = [ [project.optional-dependencies] testing = [ "dj-database-url==2.1.0", - "pre-commit==3.4.0" + "pre-commit==3.4.0", + "coverage==7.3.4", ] ci = [ "tox==4.11.3", "tox-gh-actions==3.1.3", # Allow use of pyenv for virtual environments. To enable you need to set `VIRTUALENV_DISCOVERY=pyenv` in the shell. + # This is useful to help tox find the correct python version when using pyenv. "virtualenv-pyenv==0.4.0" ] diff --git a/testmanage.py b/testmanage.py old mode 100644 new mode 100755 diff --git a/tox.ini b/tox.ini index cfc4498..2049123 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,15 @@ DB = [testenv] install_command = pip install -e ".[testing]" -U {opts} {packages} -commands = coverage run testmanage.py test --deprecation all {posargs: -v 2} +commands = + # Run coverage in append mode so that we get a combined report over all environments. + # This can not be combined with parallel mode. + # This only affects local working, because each env is run on a different runner in CI. + coverage run -a testmanage.py test --deprecation all {posargs: -v 2} + +commands_post = + # The report is converted to json to be uploaded to codecov. + coverage json --data-file .coverage -o .coverage.json basepython = python3.8: python3.8 @@ -27,6 +35,7 @@ basepython = python3.11: python3.11 deps = + # Coverage is required here (even though it's in pyproject.toml) to make it work on GitHub Actions coverage django3.2: Django>=3.2,<4.0