refactor: add missing exports, better error handling, and handle boolean query params (#1422)
Some checks are pending
Docs - build & deploy / docs (push) Waiting to run
Run tests / build (ubuntu-latest, 3.10) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.11) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.12) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.13) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.8) (push) Waiting to run
Run tests / build (ubuntu-latest, 3.9) (push) Waiting to run
Run tests / build (windows-latest, 3.10) (push) Waiting to run
Run tests / build (windows-latest, 3.11) (push) Waiting to run
Run tests / build (windows-latest, 3.12) (push) Waiting to run
Run tests / build (windows-latest, 3.13) (push) Waiting to run
Run tests / build (windows-latest, 3.8) (push) Waiting to run
Run tests / build (windows-latest, 3.9) (push) Waiting to run
Run tests / test_docs (3.13) (push) Waiting to run
Run tests / test_sampleproject (3.13) (push) Waiting to run

This commit is contained in:
Juro Oravec 2025-09-30 21:49:28 +02:00 committed by GitHub
parent e9d1b6c4b2
commit 8957befd1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 552 additions and 371 deletions

View file

@ -29,6 +29,26 @@
Each yield operation is independent and returns its own `(html, error)` tuple, allowing you to handle each rendering result separately. Each yield operation is independent and returns its own `(html, error)` tuple, allowing you to handle each rendering result separately.
#### Fix
- Improve formatting when an exception is raised while rendering components. Error messages with newlines should now be properly formatted.
- Add missing exports for `OnComponentRenderedContext`, `OnSlotRenderedContext`, `OnTemplateCompiledContext`, `OnTemplateLoadedContext`.
#### Refactor
- Changes to how `get_component_url()` handles query parameters:
- `True` values are now converted to boolean flags (e.g. `?enabled` instead of `?enabled=True`).
- `False` and `None` values are now filtered out.
```py
url = get_component_url(
MyComponent,
query={"abc": 123, "enabled": True, "debug": False, "none_key": None},
)
# /components/ext/view/components/c1ab2c3?abc=123&enabled
```
## v0.141.6 ## v0.141.6
#### Fix #### Fix

View file

@ -10,8 +10,8 @@ For web applications, it's common to define endpoints that serve HTML content (A
django-components has a suite of features that help you write and manage views and their URLs: django-components has a suite of features that help you write and manage views and their URLs:
- For each component, you can define methods for handling HTTP requests (GET, POST, etc.) - `get()`, `post()`, etc. - For each component, you can define methods for handling HTTP requests (GET, POST, etc.) - `get()`, `post()`, etc.
- Use [`Component.as_view()`](../../../reference/api#django_components.Component.as_view) to be able to use your Components with Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/). This works the same way as [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view). - Use [`Component.as_view()`](../../../reference/api#django_components.Component.as_view) to be able to use your Components with Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/). This works the same way as [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view).
- To avoid having to manually define the endpoints for each component, you can set the component to be "public" with [`Component.View.public = True`](../../../reference/api#django_components.ComponentView.public). This will automatically create a URL for the component. To retrieve the component URL, use [`get_component_url()`](../../../reference/api#django_components.get_component_url). - To avoid having to manually define the endpoints for each component, you can set the component to be "public" with [`Component.View.public = True`](../../../reference/api#django_components.ComponentView.public). This will automatically create a URL for the component. To retrieve the component URL, use [`get_component_url()`](../../../reference/api#django_components.get_component_url).
@ -60,7 +60,6 @@ class Calendar(Component):
Each of these receive the [`HttpRequest`](https://docs.djangoproject.com/en/5.2/ref/request-response/#django.http.HttpRequest) object as the first argument. Each of these receive the [`HttpRequest`](https://docs.djangoproject.com/en/5.2/ref/request-response/#django.http.HttpRequest) object as the first argument.
<!-- TODO_V1 REMOVE --> <!-- TODO_V1 REMOVE -->
!!! warning !!! warning
@ -111,7 +110,7 @@ urlpatterns = [
[`Component.as_view()`](../../../reference/api#django_components.Component.as_view) [`Component.as_view()`](../../../reference/api#django_components.Component.as_view)
internally calls [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view), passing the component internally calls [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view), passing the component
instance as one of the arguments. class as one of the arguments.
## Register URLs automatically ## Register URLs automatically
@ -144,8 +143,14 @@ This way you don't have to mix your app URLs with component URLs.
```py ```py
url = get_component_url( url = get_component_url(
MyComponent, MyComponent,
query={"foo": "bar"}, query={"foo": "bar", "enabled": True, "debug": False, "unused": None},
fragment="baz", fragment="baz",
) )
# /components/ext/view/components/c1ab2c3?foo=bar#baz # /components/ext/view/components/c1ab2c3?foo=bar&enabled#baz
``` ```
**Query parameter handling:**
- `True` values are rendered as flag parameters without values (e.g., `?enabled`)
- `False` and `None` values are omitted from the URL
- Other values are rendered normally (e.g., `?foo=bar`)

View file

@ -308,6 +308,11 @@ name | type | description
heading_level: 3 heading_level: 3
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.extension.OnComponentRenderedContext
options:
heading_level: 3
show_if_no_docstring: true
::: django_components.extension.OnComponentUnregisteredContext ::: django_components.extension.OnComponentUnregisteredContext
options: options:
heading_level: 3 heading_level: 3
@ -323,3 +328,18 @@ name | type | description
heading_level: 3 heading_level: 3
show_if_no_docstring: true show_if_no_docstring: true
::: django_components.extension.OnSlotRenderedContext
options:
heading_level: 3
show_if_no_docstring: true
::: django_components.extension.OnTemplateCompiledContext
options:
heading_level: 3
show_if_no_docstring: true
::: django_components.extension.OnTemplateLoadedContext
options:
heading_level: 3
show_if_no_docstring: true

View file

@ -13,6 +13,7 @@ playwright
requests requests
types-requests types-requests
whitenoise whitenoise
pydantic
pygments pygments
pygments-djc pygments-djc
asv asv

View file

@ -4,17 +4,17 @@
# #
# pip-compile requirements-dev.in # pip-compile requirements-dev.in
# #
asgiref==3.9.1 annotated-types==0.7.0
# via pydantic
asgiref==3.9.2
# via django # via django
asv==0.6.5 asv==0.6.5
# via -r requirements-dev.in # via -r requirements-dev.in
asv-runner==0.2.1 asv-runner==0.2.1
# via asv # via asv
backports-asyncio-runner==1.2.0
# via pytest-asyncio
build==1.3.0 build==1.3.0
# via asv # via asv
cachetools==6.1.0 cachetools==6.2.0
# via tox # via tox
certifi==2025.8.3 certifi==2025.8.3
# via requests # via requests
@ -28,7 +28,7 @@ colorama==0.4.6
# via tox # via tox
distlib==0.4.0 distlib==0.4.0
# via virtualenv # via virtualenv
django==4.2.24 django==5.2.6
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# django-template-partials # django-template-partials
@ -36,15 +36,13 @@ django-template-partials==25.2
# via -r requirements-dev.in # via -r requirements-dev.in
djc-core-html-parser==1.0.2 djc-core-html-parser==1.0.2
# via -r requirements-dev.in # via -r requirements-dev.in
exceptiongroup==1.3.0
# via pytest
filelock==3.19.1 filelock==3.19.1
# via # via
# tox # tox
# virtualenv # virtualenv
greenlet==3.2.4 greenlet==3.2.4
# via playwright # via playwright
identify==2.6.13 identify==2.6.14
# via pre-commit # via pre-commit
idna==3.10 idna==3.10
# via requests # via requests
@ -52,7 +50,6 @@ importlib-metadata==8.7.0
# via # via
# asv # asv
# asv-runner # asv-runner
# build
iniconfig==2.1.0 iniconfig==2.1.0
# via pytest # via pytest
json5==0.12.1 json5==0.12.1
@ -74,11 +71,11 @@ pathspec==0.12.1
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# mypy # mypy
platformdirs==4.3.8 platformdirs==4.4.0
# via # via
# tox # tox
# virtualenv # virtualenv
playwright==1.54.0 playwright==1.55.0
# via -r requirements-dev.in # via -r requirements-dev.in
pluggy==1.6.0 pluggy==1.6.0
# via # via
@ -86,6 +83,10 @@ pluggy==1.6.0
# tox # tox
pre-commit==4.3.0 pre-commit==4.3.0
# via -r requirements-dev.in # via -r requirements-dev.in
pydantic==2.11.9
# via -r requirements-dev.in
pydantic-core==2.33.2
# via pydantic
pyee==13.0.0 pyee==13.0.0
# via playwright # via playwright
pygments==2.19.2 pygments==2.19.2
@ -101,52 +102,45 @@ pyproject-api==1.9.1
# via tox # via tox
pyproject-hooks==1.2.0 pyproject-hooks==1.2.0
# via build # via build
pytest==8.4.1 pytest==8.4.2
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# pytest-asyncio # pytest-asyncio
# pytest-django # pytest-django
# syrupy # syrupy
pytest-asyncio==1.1.0 pytest-asyncio==1.2.0
# via -r requirements-dev.in # via -r requirements-dev.in
pytest-django==4.11.1 pytest-django==4.11.1
# via -r requirements-dev.in # via -r requirements-dev.in
pyyaml==6.0.2 pyyaml==6.0.3
# via # via
# asv # asv
# pre-commit # pre-commit
requests==2.32.4 requests==2.32.5
# via -r requirements-dev.in # via -r requirements-dev.in
ruff==0.13.2 ruff==0.13.2
# via -r requirements-dev.in # via -r requirements-dev.in
sqlparse==0.5.3 sqlparse==0.5.3
# via django # via django
syrupy==4.9.1 syrupy==5.0.0
# via -r requirements-dev.in # via -r requirements-dev.in
tabulate==0.9.0 tabulate==0.9.0
# via asv # via asv
tomli==2.2.1 tox==4.30.2
# via
# asv
# build
# mypy
# pyproject-api
# pytest
# tox
tox==4.28.4
# via -r requirements-dev.in # via -r requirements-dev.in
types-requests==2.32.4.20250809 types-requests==2.32.4.20250913
# via -r requirements-dev.in # via -r requirements-dev.in
typing-extensions==4.14.1 typing-extensions==4.15.0
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# asgiref
# exceptiongroup
# mypy # mypy
# pydantic
# pydantic-core
# pyee # pyee
# pytest-asyncio # pytest-asyncio
# tox # typing-inspection
# virtualenv typing-inspection==0.4.1
# via pydantic
urllib3==2.5.0 urllib3==2.5.0
# via # via
# requests # requests
@ -157,7 +151,7 @@ virtualenv==20.34.0
# asv # asv
# pre-commit # pre-commit
# tox # tox
whitenoise==6.9.0 whitenoise==6.11.0
# via -r requirements-dev.in # via -r requirements-dev.in
zipp==3.23.0 zipp==3.23.0
# via importlib-metadata # via importlib-metadata

View file

@ -34,7 +34,6 @@ from django_components.component_registry import (
registry, registry,
all_registries, all_registries,
) )
from django_components.components import DynamicComponent
from django_components.dependencies import DependenciesStrategy, render_dependencies from django_components.dependencies import DependenciesStrategy, render_dependencies
from django_components.extension import ( from django_components.extension import (
ComponentExtension, ComponentExtension,
@ -47,6 +46,10 @@ from django_components.extension import (
OnComponentClassDeletedContext, OnComponentClassDeletedContext,
OnComponentInputContext, OnComponentInputContext,
OnComponentDataContext, OnComponentDataContext,
OnComponentRenderedContext,
OnSlotRenderedContext,
OnTemplateCompiledContext,
OnTemplateLoadedContext,
) )
from django_components.extensions.cache import ComponentCache from django_components.extensions.cache import ComponentCache
from django_components.extensions.defaults import ComponentDefaults, Default from django_components.extensions.defaults import ComponentDefaults, Default
@ -81,6 +84,9 @@ from django_components.util.loader import ComponentFileEntry, get_component_dirs
from django_components.util.routing import URLRoute, URLRouteHandler from django_components.util.routing import URLRoute, URLRouteHandler
from django_components.util.types import Empty from django_components.util.types import Empty
# NOTE: Import built-in components last to avoid circular imports
from django_components.components import DynamicComponent
# isort: on # isort: on
@ -122,10 +128,14 @@ __all__ = [
"OnComponentDataContext", "OnComponentDataContext",
"OnComponentInputContext", "OnComponentInputContext",
"OnComponentRegisteredContext", "OnComponentRegisteredContext",
"OnComponentRenderedContext",
"OnComponentUnregisteredContext", "OnComponentUnregisteredContext",
"OnRegistryCreatedContext", "OnRegistryCreatedContext",
"OnRegistryDeletedContext", "OnRegistryDeletedContext",
"OnRenderGenerator", "OnRenderGenerator",
"OnSlotRenderedContext",
"OnTemplateCompiledContext",
"OnTemplateLoadedContext",
"ProvideNode", "ProvideNode",
"RegistrySettings", "RegistrySettings",
"ShorthandComponentFormatter", "ShorthandComponentFormatter",

View file

@ -48,6 +48,12 @@ def get_component_url(
`get_component_url()` optionally accepts `query` and `fragment` arguments. `get_component_url()` optionally accepts `query` and `fragment` arguments.
**Query parameter handling:**
- `True` values are rendered as flag parameters without values (e.g., `?enabled`)
- `False` and `None` values are omitted from the URL
- Other values are rendered normally (e.g., `?foo=bar`)
**Example:** **Example:**
```py ```py
@ -60,10 +66,10 @@ def get_component_url(
# Get the URL for the component # Get the URL for the component
url = get_component_url( url = get_component_url(
MyComponent, MyComponent,
query={"foo": "bar"}, query={"foo": "bar", "enabled": True, "debug": False, "unused": None},
fragment="baz", fragment="baz",
) )
# /components/ext/view/components/c1ab2c3?foo=bar#baz # /components/ext/view/components/c1ab2c3?foo=bar&enabled#baz
``` ```
""" """
view_cls: Optional[Type[ComponentView]] = getattr(component, "View", None) view_cls: Optional[Type[ComponentView]] = getattr(component, "View", None)

View file

@ -20,20 +20,27 @@ def component_error_message(component_path: List[str]) -> Generator[None, None,
components = getattr(err, "_components", []) components = getattr(err, "_components", [])
components = err._components = [*component_path, *components] # type: ignore[attr-defined] components = err._components = [*component_path, *components] # type: ignore[attr-defined]
# Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
if len(err.args) and err.args[0] is not None:
if not components:
orig_msg = str(err.args[0])
else:
orig_msg = str(err.args[0]).split("\n", 1)[-1]
else:
orig_msg = str(err)
# Format component path as # Format component path as
# "MyPage > MyComponent > MyComponent(slot:content) > Base(slot:tab)" # "MyPage > MyComponent > MyComponent(slot:content) > Base(slot:tab)"
comp_path = " > ".join(components) comp_path = " > ".join(components)
prefix = f"An error occured while rendering components {comp_path}:\n" prefix = f"An error occured while rendering components {comp_path}:\n"
# Access the exception's message, see https://stackoverflow.com/a/75549200/9788634
if len(err.args) and err.args[0] is not None:
orig_msg = str(err.args[0])
if components and "An error occured while rendering components" in orig_msg:
orig_msg = str(err.args[0]).split("\n", 1)[-1]
else:
# When the exception has no message, it may be that the exception
# does NOT rely on the `args` attribute. Such case is for example
# Pydantic exceptions.
#
# In this case, we still try to use the `args` attribute, but
# it's not guaranteed to work. So we also print out the component
# path ourselves.
print(prefix) # noqa: T201
orig_msg = str(err)
err.args = (prefix + orig_msg,) # tuple of one err.args = (prefix + orig_msg,) # tuple of one
# `from None` should still raise the original error, but without showing this # `from None` should still raise the original error, but without showing this

View file

@ -162,12 +162,35 @@ def format_url(url: str, query: Optional[Dict] = None, fragment: Optional[str] =
``` ```
`query` and `fragment` are optional, and not applied if `None`. `query` and `fragment` are optional, and not applied if `None`.
Boolean `True` values in query parameters are rendered as flag parameters without values.
`False` and `None` values in query parameters are omitted.
```py
url = format_url(
url="https://example.com",
query={"foo": "bar", "baz": None, "enabled": True, "debug": False},
)
# https://example.com?foo=bar&enabled
```
""" """
parts = parse.urlsplit(url) parts = parse.urlsplit(url)
fragment_enc = parse.quote(fragment or parts.fragment, safe="") fragment_enc = parse.quote(fragment or parts.fragment, safe="")
base_qs = dict(parse.parse_qsl(parts.query)) base_qs = dict(parse.parse_qsl(parts.query))
merged = {**base_qs, **(query or {})} # Filter out `None` and `False` values
encoded_qs = parse.urlencode(merged, safe="") filtered_query = {k: v for k, v in (query or {}).items() if v is not None and v is not False}
merged = {**base_qs, **filtered_query}
# Handle boolean True values as flag parameters (no explicit value)
query_parts = []
for key, value in merged.items():
if value is True:
query_parts.append(parse.quote_plus(str(key)))
else:
query_parts.append(f"{parse.quote_plus(str(key))}={parse.quote_plus(str(value))}")
encoded_qs = "&".join(query_parts)
return parse.urlunsplit(parts._replace(query=encoded_qs, fragment=fragment_enc)) return parse.urlunsplit(parts._replace(query=encoded_qs, fragment=fragment_enc))

View file

@ -1471,6 +1471,56 @@ class TestComponentRender:
): ):
Root.render() Root.render()
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_pydantic_exception(self, components_settings):
from pydantic import BaseModel, ValidationError
@register("broken")
class Broken(Component):
template: types.django_html = """
{% load component_tags %}
<div> injected: {{ data|safe }} </div>
<main>
{% slot "content" default / %}
</main>
"""
class Kwargs(BaseModel):
data1: str
def get_template_data(self, args, kwargs: Kwargs, slots, context):
return {"data": kwargs.data1}
@register("parent")
class Parent(Component):
def get_template_data(self, args, kwargs, slots, context):
return {"data": kwargs["data"]}
template: types.django_html = """
{% load component_tags %}
{% component "broken" %}
{% slot "content" default / %}
{% endcomponent %}
"""
@register("root")
class Root(Component):
template: types.django_html = """
{% load component_tags %}
{% component "parent" data=123 %}
{% fill "content" %}
456
{% endfill %}
{% endcomponent %}
"""
# NOTE: We're unable to insert the component path in the Pydantic's exception message
with pytest.raises(
ValidationError,
match=re.escape("1 validation error for Kwargs\ndata1\n Field required"),
):
Root.render()
@djc_test @djc_test
class TestComponentHook: class TestComponentHook:

View file

@ -0,0 +1,349 @@
import re
from typing import NamedTuple
import pytest
from django.template import Context, Template
from pytest_django.asserts import assertHTMLEqual
from django_components import AlreadyRegistered, Component, DynamicComponent, NotRegistered, registry, types
from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
setup_test_config({"autodiscover": False})
@djc_test
class TestDynamicComponent:
def _gen_simple_component(self):
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
class Kwargs(NamedTuple):
variable: str
variable2: str
class Defaults:
variable2 = "default"
def get_template_data(self, args, kwargs: Kwargs, slots, context):
return {
"variable": kwargs.variable,
"variable2": kwargs.variable2,
}
class Media:
css = "style.css"
js = "script.js"
return SimpleComponent
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_basic__python(self, components_settings):
registry.register(name="test", component=self._gen_simple_component())
rendered = DynamicComponent.render(
kwargs={
"is": "test",
"variable": "variable",
},
)
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3e data-djc-id-ca1bc3f>variable</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_basic__template(self, components_settings):
registry.register(name="test", component=self._gen_simple_component())
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component "dynamic" is="test" variable="variable" %}{% endcomponent %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_call_with_invalid_name(self, components_settings):
registry.register(name="test", component=self._gen_simple_component())
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component "dynamic" is="haber_der_baber" variable="variable" %}{% endcomponent %}
"""
template = Template(simple_tag_template)
with pytest.raises(NotRegistered, match=re.escape("The component 'haber_der_baber' was not found")):
template.render(Context({}))
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_called_with_variable_as_name(self, components_settings):
registry.register(name="test", component=self._gen_simple_component())
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name variable="variable" %}{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_called_with_variable_as_spread(self, components_settings):
registry.register(name="test", component=self._gen_simple_component())
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component "dynamic" ...props %}{% endcomponent %}
"""
template = Template(simple_tag_template)
rendered = template.render(
Context(
{
"props": {
"is": "test",
"variable": "variable",
},
},
),
)
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_as_class(self, components_settings):
SimpleComponent = self._gen_simple_component()
registry.register(name="test", component=SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component "dynamic" is=comp_cls variable="variable" %}{% endcomponent %}
"""
template = Template(simple_tag_template)
rendered = template.render(
Context(
{
"comp_cls": SimpleComponent,
},
),
)
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(
parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
components_settings={
"tag_formatter": "django_components.component_shorthand_formatter",
"autodiscover": False,
},
)
def test_shorthand_formatter(self, components_settings):
from django_components.apps import ComponentsConfig
ComponentsConfig.ready(None) # type: ignore[arg-type]
registry.register(name="test", component=self._gen_simple_component())
simple_tag_template: types.django_html = """
{% load component_tags %}
{% dynamic is="test" variable="variable" %}{% enddynamic %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(rendered, "Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>\n")
@djc_test(
parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
components_settings={
"dynamic_component_name": "uno_reverse",
"tag_formatter": "django_components.component_shorthand_formatter",
"autodiscover": False,
},
)
def test_component_name_is_configurable(self, components_settings):
from django_components.apps import ComponentsConfig
ComponentsConfig.ready(None) # type: ignore[arg-type]
registry.register(name="test", component=self._gen_simple_component())
simple_tag_template: types.django_html = """
{% load component_tags %}
{% uno_reverse is="test" variable="variable" %}{% enduno_reverse %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_raises_already_registered_on_name_conflict(self, components_settings):
with pytest.raises(
AlreadyRegistered,
match=re.escape('The component "dynamic" has already been registered'),
):
registry.register(name="dynamic", component=self._gen_simple_component())
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_called_with_default_slot(self, components_settings):
class SimpleSlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot: {% slot "default" default / %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"variable": kwargs["variable"],
"variable2": kwargs.get("variable2", "default"),
}
registry.register(name="test", component=SimpleSlottedComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name variable="variable" %}
HELLO_FROM_SLOT
{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>
Slot: HELLO_FROM_SLOT
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_called_with_named_slots(self, components_settings):
class SimpleSlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "default" default / %}
Slot 2: {% slot "two" / %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"variable": kwargs["variable"],
"variable2": kwargs.get("variable2", "default"),
}
registry.register(name="test", component=SimpleSlottedComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name variable="variable" %}
{% fill "default" %}
HELLO_FROM_SLOT_1
{% endfill %}
{% fill "two" %}
HELLO_FROM_SLOT_2
{% endfill %}
{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-ca1bc41 data-djc-id-ca1bc42>variable</strong>
Slot 1: HELLO_FROM_SLOT_1
Slot 2: HELLO_FROM_SLOT_2
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_ignores_invalid_slots(self, components_settings):
class SimpleSlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "default" default / %}
Slot 2: {% slot "two" / %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"variable": kwargs["variable"],
"variable2": kwargs.get("variable2", "default"),
}
registry.register(name="test", component=SimpleSlottedComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name variable="variable" %}
{% fill "default" %}
HELLO_FROM_SLOT_1
{% endfill %}
{% fill "three" %}
HELLO_FROM_SLOT_2
{% endfill %}
{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-ca1bc41 data-djc-id-ca1bc42>variable</strong>
Slot 1: HELLO_FROM_SLOT_1
Slot 2:
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_raises_on_invalid_input(self, components_settings):
registry.register(name="test", component=self._gen_simple_component())
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name invalid_variable="variable" %}{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
with pytest.raises(
TypeError,
match=re.escape("got an unexpected keyword argument 'invalid_variable'"),
):
template.render(Context({}))

View file

@ -307,6 +307,18 @@ class TestComponentAsView:
== f"/components/ext/view/components/{TestComponent.class_id}/?f%27oo=b+ar%26ba%27z#q%20u%22x" == f"/components/ext/view/components/{TestComponent.class_id}/?f%27oo=b+ar%26ba%27z#q%20u%22x"
) )
# Converts `True` to no flag parameter (no value, e.g. `enabled` instead of `enabled=True`)
# And filters out `False` and `None` values
component_url3 = get_component_url(
TestComponent,
query={"f'oo": "b ar&ba'z", "true_key": True, "false_key": False, "none_key": None},
fragment='q u"x',
)
assert (
component_url3
== f"/components/ext/view/components/{TestComponent.class_id}/?f%27oo=b+ar%26ba%27z&true_key#q%20u%22x"
)
# Merges query params from original URL # Merges query params from original URL
component_url4 = format_url( component_url4 = format_url(
"/components/ext/view/components/123?foo=123&bar=456#abc", "/components/ext/view/components/123?foo=123&bar=456#abc",

View file

@ -1,11 +1,10 @@
import re import re
from typing import NamedTuple
import pytest import pytest
from django.template import Context, Template, TemplateSyntaxError from django.template import Context, Template, TemplateSyntaxError
from pytest_django.asserts import assertHTMLEqual from pytest_django.asserts import assertHTMLEqual
from django_components import AlreadyRegistered, Component, NotRegistered, register, registry, types from django_components import Component, NotRegistered, register, registry, types
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
@ -208,323 +207,6 @@ class TestComponentTemplateTag:
) )
@djc_test
class TestDynamicComponentTemplateTag:
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
class Kwargs(NamedTuple):
variable: str
variable2: str
class Defaults:
variable2 = "default"
def get_template_data(self, args, kwargs: Kwargs, slots, context):
return {
"variable": kwargs.variable,
"variable2": kwargs.variable2,
}
class Media:
css = "style.css"
js = "script.js"
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_basic(self, components_settings):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component "dynamic" is="test" variable="variable" %}{% endcomponent %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_call_with_invalid_name(self, components_settings):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component "dynamic" is="haber_der_baber" variable="variable" %}{% endcomponent %}
"""
template = Template(simple_tag_template)
with pytest.raises(NotRegistered, match=re.escape("The component 'haber_der_baber' was not found")):
template.render(Context({}))
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_called_with_variable_as_name(self, components_settings):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name variable="variable" %}{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_called_with_variable_as_spread(self, components_settings):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component "dynamic" ...props %}{% endcomponent %}
"""
template = Template(simple_tag_template)
rendered = template.render(
Context(
{
"props": {
"is": "test",
"variable": "variable",
},
}
)
)
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_as_class(self, components_settings):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component "dynamic" is=comp_cls variable="variable" %}{% endcomponent %}
"""
template = Template(simple_tag_template)
rendered = template.render(
Context(
{
"comp_cls": self.SimpleComponent,
}
)
)
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(
parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
components_settings={
"tag_formatter": "django_components.component_shorthand_formatter",
"autodiscover": False,
},
)
def test_shorthand_formatter(self, components_settings):
from django_components.apps import ComponentsConfig
ComponentsConfig.ready(None) # type: ignore[arg-type]
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% dynamic is="test" variable="variable" %}{% enddynamic %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(rendered, "Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>\n")
@djc_test(
parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
components_settings={
"dynamic_component_name": "uno_reverse",
"tag_formatter": "django_components.component_shorthand_formatter",
"autodiscover": False,
},
)
def test_component_name_is_configurable(self, components_settings):
from django_components.apps import ComponentsConfig
ComponentsConfig.ready(None) # type: ignore[arg-type]
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% uno_reverse is="test" variable="variable" %}{% enduno_reverse %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_raises_already_registered_on_name_conflict(self, components_settings):
with pytest.raises(
AlreadyRegistered,
match=re.escape('The component "dynamic" has already been registered'),
):
registry.register(name="dynamic", component=self.SimpleComponent)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_called_with_default_slot(self, components_settings):
class SimpleSlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot: {% slot "default" default / %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"variable": kwargs["variable"],
"variable2": kwargs.get("variable2", "default"),
}
registry.register(name="test", component=SimpleSlottedComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name variable="variable" %}
HELLO_FROM_SLOT
{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-ca1bc3f data-djc-id-ca1bc40>variable</strong>
Slot: HELLO_FROM_SLOT
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_component_called_with_named_slots(self, components_settings):
class SimpleSlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "default" default / %}
Slot 2: {% slot "two" / %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"variable": kwargs["variable"],
"variable2": kwargs.get("variable2", "default"),
}
registry.register(name="test", component=SimpleSlottedComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name variable="variable" %}
{% fill "default" %}
HELLO_FROM_SLOT_1
{% endfill %}
{% fill "two" %}
HELLO_FROM_SLOT_2
{% endfill %}
{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-ca1bc41 data-djc-id-ca1bc42>variable</strong>
Slot 1: HELLO_FROM_SLOT_1
Slot 2: HELLO_FROM_SLOT_2
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_ignores_invalid_slots(self, components_settings):
class SimpleSlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
Variable: <strong>{{ variable }}</strong>
Slot 1: {% slot "default" default / %}
Slot 2: {% slot "two" / %}
"""
def get_template_data(self, args, kwargs, slots, context):
return {
"variable": kwargs["variable"],
"variable2": kwargs.get("variable2", "default"),
}
registry.register(name="test", component=SimpleSlottedComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name variable="variable" %}
{% fill "default" %}
HELLO_FROM_SLOT_1
{% endfill %}
{% fill "three" %}
HELLO_FROM_SLOT_2
{% endfill %}
{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"""
Variable: <strong data-djc-id-ca1bc41 data-djc-id-ca1bc42>variable</strong>
Slot 1: HELLO_FROM_SLOT_1
Slot 2:
""",
)
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)
def test_raises_on_invalid_input(self, components_settings):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="test" %}
{% component "dynamic" is=component_name invalid_variable="variable" %}{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
with pytest.raises(
TypeError,
match=re.escape("got an unexpected keyword argument 'invalid_variable'"),
):
template.render(Context({}))
@djc_test @djc_test
class TestMultiComponent: class TestMultiComponent:
@djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR)

View file

@ -39,6 +39,7 @@ deps =
# Othrwise we get error: # Othrwise we get error:
# playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium-1140/chrome-linux/chrome # playwright._impl._errors.Error: BrowserType.launch: Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium-1140/chrome-linux/chrome
playwright==1.48.0 playwright==1.48.0
pydantic
requests requests
types-requests types-requests
whitenoise whitenoise
@ -60,6 +61,7 @@ deps =
syrupy # snapshot testing syrupy # snapshot testing
# NOTE: Keep playwright in sync with the version in requirements-ci.txt # NOTE: Keep playwright in sync with the version in requirements-ci.txt
playwright==1.48.0 playwright==1.48.0
pydantic
requests requests
types-requests types-requests
whitenoise whitenoise