refactor: make it optional having to specify parent class of Args, Kwargs, Slots, etc (#1466)

This commit is contained in:
Juro Oravec 2025-10-21 15:30:08 +02:00 committed by GitHub
parent 0aeb96fa40
commit c37628dea0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 661 additions and 299 deletions

View file

@ -1,5 +1,60 @@
# Release notes
## v0.142.4
#### Refactor
- Simpler syntax for defining component inputs.
When defining `Args`, `Kwargs`, `Slots`, `JsData`, `CssData`, `TemplateData`, these data classes now don't have to subclass any other class.
If they are not subclassing (nor `@dataclass`), these data classes will be automatically converted to `NamedTuples`:
Before - the `Args`, `Kwargs`, and `Slots` (etc..) had to be NamedTuples, dataclasses, or Pydantic models:
```py
from typing import NamedTuple
from django_components import Component
class Button(Component):
class Args(NamedTuple):
size: int
text: str
class Kwargs(NamedTuple):
variable: str
maybe_var: Optional[int] = None
class Slots(NamedTuple):
my_slot: Optional[SlotInput] = None
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
...
```
Now these classes are automatically converted to `NamedTuples` if they don't subclass anything else:
```py
class Button(Component):
class Args: # Same as `Args(NamedTuple)`
size: int
text: str
class Kwargs: # Same as `Kwargs(NamedTuple)`
variable: str
maybe_var: Optional[int] = None
class Slots: # Same as `Slots(NamedTuple)`
my_slot: Optional[SlotInput] = None
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
...
```
- Extension authors: The `ExtensionComponentConfig` can be instantiated with `None` instead of a component instance.
This allows to call component-level extension methods outside of the normal rendering lifecycle.
## v0.142.3
#### Fix

View file

@ -405,21 +405,21 @@ Avoid needless errors with [type hints and runtime input validation](https://dja
To opt-in to input validation, define types for component's args, kwargs, slots, and more:
```py
from typing import NamedTuple, Optional
from typing import Optional
from django.template import Context
from django_components import Component, Slot, SlotInput
class Button(Component):
class Args(NamedTuple):
class Args:
size: int
text: str
class Kwargs(NamedTuple):
class Kwargs:
variable: str
another: int
maybe_var: Optional[int] = None # May be omitted
class Slots(NamedTuple):
class Slots:
my_slot: Optional[SlotInput] = None
another_slot: SlotInput

View file

@ -101,7 +101,7 @@ For live examples, see the [Examples](../../examples/index.md).
It's also a good idea to have a common prefix for your components, so they can be easily distinguished from users' components. In the example below, we use the prefix `my_` / `My`.
```djc_py
from typing import NamedTuple, Optional
from typing import Optional
from django_components import Component, SlotInput, register, types
from myapp.templatetags.mytags import comp_registry
@ -111,16 +111,16 @@ For live examples, see the [Examples](../../examples/index.md).
@register("my_menu", registry=comp_registry)
class MyMenu(Component):
# Define the types
class Args(NamedTuple):
class Args:
size: int
text: str
class Kwargs(NamedTuple):
class Kwargs:
vertical: Optional[bool] = None
klass: Optional[str] = None
style: Optional[str] = None
class Slots(NamedTuple):
class Slots:
default: Optional[SlotInput] = None
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):

View file

@ -65,12 +65,14 @@ E.g.:
Each method of the nested classes has access to the `component` attribute,
which points to the component instance.
This may be `None` if the methods does NOT run during the rendering.
```python
class MyTable(Component):
class MyExtension:
def get(self, request):
# `self.component` points to the instance of `MyTable` Component.
return self.component.render_to_response(request=request)
return self.component.kwargs.some_input
```
### Example: Component as View

View file

@ -289,14 +289,14 @@ To implement this, we render the fallback slot in [`on_render()`](../../../refer
and return it if an error occured:
```djc_py
from typing import NamedTuple, Optional
from typing import Optional
from django.template import Context, Template
from django.utils.safestring import mark_safe
from django_components import Component, OnRenderGenerator, SlotInput, types
class ErrorFallback(Component):
class Slots(NamedTuple):
class Slots:
default: Optional[SlotInput] = None
fallback: Optional[SlotInput] = None

View file

@ -74,7 +74,7 @@ and so `selected_items` will be set to `[1, 2, 3]`.
```py
class ProfileCard(Component):
class Kwargs(NamedTuple):
class Kwargs:
show_details: bool = True
```

View file

@ -15,7 +15,7 @@ Each method handles the data independently - you can define different data for t
```python
class ProfileCard(Component):
class Kwargs(NamedTuple):
class Kwargs:
user_id: int
show_details: bool
@ -51,7 +51,7 @@ If [`get_template_data()`](../../../reference/api/#django_components.Component.g
class ProfileCard(Component):
template_file = "profile_card.html"
class Kwargs(NamedTuple):
class Kwargs:
user_id: int
show_details: bool
@ -182,10 +182,10 @@ class ProfileCard(Component):
```py
class ProfileCard(Component):
class Args(NamedTuple):
class Args:
user_id: int
class Kwargs(NamedTuple):
class Kwargs:
show_details: bool
# Access inputs directly as parameters
@ -228,10 +228,10 @@ class ProfileCard(Component):
```py
class ProfileCard(Component):
class Args(NamedTuple):
class Args:
user_id: int
class Kwargs(NamedTuple):
class Kwargs:
show_details: bool
def get_template_data(self, args: Args, kwargs: Kwargs, slots, context):
@ -320,7 +320,7 @@ from django_components import Component, Default, register
@register("profile_card")
class ProfileCard(Component):
class Kwargs(NamedTuple):
class Kwargs:
show_details: bool
class Defaults:
@ -344,7 +344,7 @@ class ProfileCard(Component):
```py
class ProfileCard(Component):
class Kwargs(NamedTuple):
class Kwargs:
show_details: bool = True
```
@ -391,18 +391,18 @@ This will also validate the inputs at runtime, as the type classes will be insta
Read more about [Component typing](../../fundamentals/typing_and_validation).
```python
from typing import NamedTuple, Optional
from typing import Optional
from django_components import Component, SlotInput
class Button(Component):
class Args(NamedTuple):
class Args:
name: str
class Kwargs(NamedTuple):
class Kwargs:
surname: str
maybe_var: Optional[int] = None # May be omitted
class Slots(NamedTuple):
class Slots:
my_slot: Optional[SlotInput] = None
footer: SlotInput
@ -433,19 +433,18 @@ and [`CssData`](../../../reference/api/#django_components.Component.CssData) cla
For each data method, you can either return a plain dictionary with the data, or an instance of the respective data class.
```python
from typing import NamedTuple
from django_components import Component
class Button(Component):
class TemplateData(NamedTuple):
class TemplateData(
data1: str
data2: int
class JsData(NamedTuple):
class JsData:
js_data1: str
js_data2: int
class CssData(NamedTuple):
class CssData:
css_data1: str
css_data2: int

View file

@ -92,7 +92,7 @@ With `Args` class:
from django_components import Component
class Table(Component):
class Args(NamedTuple):
class Args:
page: int
per_page: int
@ -137,7 +137,7 @@ With `Kwargs` class:
from django_components import Component
class Table(Component):
class Kwargs(NamedTuple):
class Kwargs:
page: int
per_page: int
@ -182,7 +182,7 @@ With `Slots` class:
from django_components import Component, Slot, SlotInput
class Table(Component):
class Slots(NamedTuple):
class Slots:
header: SlotInput
footer: SlotInput

View file

@ -80,14 +80,13 @@ For a component to be renderable with the [`{% component %}`](../../../reference
For example, if you register a component under the name `"button"`:
```python
from typing import NamedTuple
from django_components import Component, register
@register("button")
class Button(Component):
template_file = "button.html"
class Kwargs(NamedTuple):
class Kwargs:
name: str
job: str
@ -207,20 +206,20 @@ The [`Component.render()`](../../../reference/api/#django_components.Component.r
This is the equivalent of calling the [`{% component %}`](../template_tags#component) tag.
```python
from typing import NamedTuple, Optional
from typing import Optional
from django_components import Component, SlotInput
class Button(Component):
template_file = "button.html"
class Args(NamedTuple):
class Args:
name: str
class Kwargs(NamedTuple):
class Kwargs:
surname: str
age: int
class Slots(NamedTuple):
class Slots:
footer: Optional[SlotInput] = None
def get_template_data(self, args, kwargs, slots, context):
@ -264,20 +263,20 @@ Any extra arguments are passed to the [`HttpResponse`](https://docs.djangoprojec
constructor.
```python
from typing import NamedTuple, Optional
from typing import Optional
from django_components import Component, SlotInput
class Button(Component):
template_file = "button.html"
class Args(NamedTuple):
class Args(
name: str
class Kwargs(NamedTuple):
class Kwargs:
surname: str
age: int
class Slots(NamedTuple):
class Slots:
footer: Optional[SlotInput] = None
def get_template_data(self, args, kwargs, slots, context):
@ -486,19 +485,19 @@ and [`Slots`](../../../reference/api/#django_components.Component.Slots) classes
Read more on [Typing and validation](../../fundamentals/typing_and_validation).
```python
from typing import NamedTuple, Optional
from typing import Optional
from django_components import Component, Slot, SlotInput
# Define the component with the types
class Button(Component):
class Args(NamedTuple):
class Args(
name: str
class Kwargs(NamedTuple):
class Kwargs:
surname: str
age: int
class Slots(NamedTuple):
class Slots:
my_slot: Optional[SlotInput] = None
footer: SlotInput

View file

@ -20,20 +20,20 @@ that allow you to define the types of args, kwargs, slots, as well as the data r
Use this to add type hints to your components, to validate the inputs at runtime, and to document them.
```py
from typing import NamedTuple, Optional
from typing import Optional
from django.template import Context
from django_components import Component, SlotInput
class Button(Component):
class Args(NamedTuple):
class Args:
size: int
text: str
class Kwargs(NamedTuple):
class Kwargs:
variable: str
maybe_var: Optional[int] = None # May be omitted
class Slots(NamedTuple):
class Slots:
my_slot: Optional[SlotInput] = None
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
@ -59,7 +59,7 @@ You can use [`Component.Args`](../../../reference/api#django_components.Componen
[`Component.Kwargs`](../../../reference/api#django_components.Component.Kwargs),
and [`Component.Slots`](../../../reference/api#django_components.Component.Slots) to type the component inputs.
When you set these classes, at render time the `args`, `kwargs`, and `slots` parameters of the data methods
When you set these input classes, the `args`, `kwargs`, and `slots` parameters of the data methods
([`get_template_data()`](../../../reference/api#django_components.Component.get_template_data),
[`get_js_data()`](../../../reference/api#django_components.Component.get_js_data),
[`get_css_data()`](../../../reference/api#django_components.Component.get_css_data))
@ -80,7 +80,7 @@ or set them to `None`, the inputs will be passed as plain lists or dictionaries,
and will not be validated.
```python
from typing_extensions import NamedTuple, TypedDict
from typing_extensions import TypedDict
from django.template import Context
from django_components import Component, Slot, SlotInput
@ -90,15 +90,15 @@ class ButtonFooterSlotData(TypedDict):
# Define the component with the types
class Button(Component):
class Args(NamedTuple):
class Args:
name: str
class Kwargs(NamedTuple):
class Kwargs:
surname: str
age: int
maybe_var: Optional[int] = None # May be omitted
class Slots(NamedTuple):
class Slots:
# Use `SlotInput` to allow slots to be given as `Slot` instance,
# plain string, or a function that returns a string.
my_slot: Optional[SlotInput] = None
@ -143,7 +143,7 @@ class Button(Component):
Args = None
Slots = None
class Kwargs(NamedTuple):
class Kwargs:
name: str
age: int
@ -195,19 +195,18 @@ If you omit the [`TemplateData`](../../../reference/api#django_components.Compon
or set them to `None`, the validation and instantiation will be skipped.
```python
from typing import NamedTuple
from django_components import Component
class Button(Component):
class TemplateData(NamedTuple):
class TemplateData:
data1: str
data2: int
class JsData(NamedTuple):
class JsData:
js_data1: str
js_data2: int
class CssData(NamedTuple):
class CssData:
css_data1: str
css_data2: int
@ -233,19 +232,18 @@ class Button(Component):
For each data method, you can either return a plain dictionary with the data, or an instance of the respective data class directly.
```python
from typing import NamedTuple
from django_components import Component
class Button(Component):
class TemplateData(NamedTuple):
class TemplateData:
data1: str
data2: int
class JsData(NamedTuple):
class JsData:
js_data1: str
js_data2: int
class CssData(NamedTuple):
class CssData:
css_data1: str
css_data2: int
@ -270,34 +268,63 @@ class Button(Component):
## Custom types
We recommend to use [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
for the `Args` class, and [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple),
So far, we've defined the input classes like `Kwargs` as simple classes.
The truth is that when these classes don't subclass anything else,
they are converted to `NamedTuples` behind the scenes.
```py
class Table(Component):
class Kwargs:
name: str
age: int
```
is the same as:
```py
class Table(Component):
class Kwargs(NamedTuple):
name: str
age: int
```
You can actually set these classes to anything you want - whether it's dataclasses,
[Pydantic models](https://docs.pydantic.dev/latest/concepts/models/), or custom classes:
```py
from typing import NamedTuple, Optional
from django_components import Component, Optional
from pydantic import BaseModel
class Button(Component):
class Args(NamedTuple):
size: int
text: str
@dataclass
class Kwargs:
variable: str
maybe_var: Optional[int] = None
class Slots(BaseModel):
my_slot: Optional[SlotInput] = None
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
...
```
We recommend:
- [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
for the `Args` class
- [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple),
[dataclasses](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass),
or [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/)
for `Kwargs`, `Slots`, `TemplateData`, `JsData`, and `CssData` classes.
However, you can use any class, as long as they meet the conditions below.
For example, here is how you can use [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/)
to validate the inputs at runtime.
```py
from django_components import Component
from pydantic import BaseModel
class Table(Component):
class Kwargs(BaseModel):
name: str
age: int
def get_template_data(self, args, kwargs, slots, context):
assert isinstance(kwargs, Table.Kwargs)
Table.render(
kwargs=Table.Kwargs(name="John", age=30),
)
```
### `Args` class
The [`Args`](../../../reference/api#django_components.Component.Args) class
@ -390,7 +417,7 @@ As a workaround:
```py
class Table(Component):
class Args(NamedTuple):
class Args:
args: List[str]
Table.render(
@ -402,7 +429,7 @@ As a workaround:
```py
class Table(Component):
class Kwargs(NamedTuple):
class Kwargs:
variable: str
another: int
# Pass any extra keys under `extra`
@ -422,17 +449,16 @@ As a workaround:
To declare that a component accepts no args, kwargs, etc, define the types with no attributes using the `pass` keyword:
```py
from typing import NamedTuple
from django_components import Component
class Button(Component):
class Args(NamedTuple):
class Args:
pass
class Kwargs(NamedTuple):
class Kwargs:
pass
class Slots(NamedTuple):
class Slots:
pass
```
@ -459,14 +485,14 @@ In the example below, `ButtonExtra` inherits `Kwargs` from `Button`, but overrid
from django_components import Component, Empty
class Button(Component):
class Args(NamedTuple):
class Args:
size: int
class Kwargs(NamedTuple):
class Kwargs:
color: str
class ButtonExtra(Button):
class Args(NamedTuple):
class Args:
name: str
size: int
@ -491,10 +517,10 @@ Compare the following:
```py
class Button(Component):
class Args(NamedTuple):
class Args:
size: int
class Kwargs(NamedTuple):
class Kwargs:
color: str
# Both `Args` and `Kwargs` are defined on the class
@ -624,23 +650,23 @@ The steps to migrate are:
Thus, the code above will become:
```py
from typing import NamedTuple, Optional
from typing import Optional
from django.template import Context
from django_components import Component, Slot, SlotInput
# The Component class does not take any generics
class Button(Component):
# Types are now defined inside the component class
class Args(NamedTuple):
class Args:
size: int
text: str
class Kwargs(NamedTuple):
class Kwargs:
variable: str
another: int
maybe_var: Optional[int] = None # May be omitted
class Slots(NamedTuple):
class Slots:
# Use `SlotInput` to allow slots to be given as `Slot` instance,
# plain string, or a function that returns a string.
my_slot: Optional[SlotInput] = None

View file

@ -1,6 +1,6 @@
# ruff: noqa: S311
import random
from typing import NamedTuple, Optional
from typing import Optional
from django_components import Component, register, types
@ -9,7 +9,7 @@ DESCRIPTION = "Dynamically render different component versions. Use for A/B test
@register("offer_card_old")
class OfferCardOld(Component):
class Kwargs(NamedTuple):
class Kwargs:
savings_percent: int
def get_template_data(self, args, kwargs, slots, context):
@ -45,7 +45,7 @@ class OfferCardNew(OfferCardOld):
@register("offer_card")
class OfferCard(Component):
class Kwargs(NamedTuple):
class Kwargs:
savings_percent: int
use_new_version: Optional[bool] = None

View file

@ -1,4 +1,4 @@
from typing import Dict, List, NamedTuple
from typing import Dict, List
from django_components import Component, register, types
@ -14,7 +14,7 @@ error_rate = {
@register("api_widget")
class ApiWidget(Component):
class Kwargs(NamedTuple):
class Kwargs:
simulate_error: bool = False
def get_template_data(self, args, kwargs: Kwargs, slots, context):

View file

@ -1,6 +1,5 @@
# ruff: noqa: S311
import random
from typing import NamedTuple
from django_components import Component, register, types
@ -9,7 +8,7 @@ DESCRIPTION = "A component that catches errors and displays fallback content, si
@register("weather_widget")
class WeatherWidget(Component):
class Kwargs(NamedTuple):
class Kwargs:
location: str
simulate_error: bool = False

View file

@ -1,4 +1,4 @@
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple
from typing import Any, Dict, List, Optional, Set, Tuple
from django_components import Component, Slot, register, types
@ -9,7 +9,7 @@ DESCRIPTION = "Form that automatically arranges fields in a grid and generates l
class FormGrid(Component):
"""Form that automatically arranges fields in a grid and generates labels."""
class Kwargs(NamedTuple):
class Kwargs:
editable: bool = True
method: str = "post"
form_content_attrs: Optional[dict] = None
@ -114,7 +114,7 @@ def prepare_form_grid(slots: Dict[str, Slot]):
# Case: Component user didn't explicitly define how to render the label
# We will create the label for the field automatically
label = FormGridLabel.render(
kwargs=FormGridLabel.Kwargs(field_name=field_name),
kwargs=FormGridLabel.Kwargs(field_name=field_name), # type: ignore[call-arg]
deps_strategy="ignore",
)
@ -134,7 +134,7 @@ class FormGridLabel(Component):
</label>
"""
class Kwargs(NamedTuple):
class Kwargs:
field_name: str
title: Optional[str] = None

View file

@ -9,7 +9,7 @@ DESCRIPTION = "Handle the entire form submission flow in a single file and witho
@register("thank_you_message")
class ThankYouMessage(Component):
class Kwargs(NamedTuple):
class Kwargs:
name: str
def get_template_data(self, args, kwargs: Kwargs, slots, context):

View file

@ -1,5 +1,3 @@
from typing import NamedTuple
from django_components import Component, register, types
DESCRIPTION = "Use HTML fragments (partials) with HTMX, AlpineJS, or plain JS."
@ -9,7 +7,7 @@ DESCRIPTION = "Use HTML fragments (partials) with HTMX, AlpineJS, or plain JS."
class SimpleFragment(Component):
"""A simple fragment with JS and CSS."""
class Kwargs(NamedTuple):
class Kwargs:
type: str
template: types.django_html = """
@ -37,7 +35,7 @@ class SimpleFragment(Component):
class AlpineFragment(Component):
"""A fragment that defines an AlpineJS component."""
class Kwargs(NamedTuple):
class Kwargs:
type: str
# The fragment is wrapped in `<template x-if="false">` so that we prevent

View file

@ -1,5 +1,3 @@
from typing import NamedTuple
from django_components import Component, register, types
DESCRIPTION = "100 nested components? Not a problem! Handle recursive rendering out of the box."
@ -7,7 +5,7 @@ DESCRIPTION = "100 nested components? Not a problem! Handle recursive rendering
@register("recursion")
class Recursion(Component):
class Kwargs(NamedTuple):
class Kwargs:
current_depth: int = 0
def get_template_data(self, args, kwargs: Kwargs, slots, context):

View file

@ -263,7 +263,7 @@ class Tablist(Component):
{% endprovide %}
"""
class Kwargs(NamedTuple):
class Kwargs:
id: Optional[str] = None
name: str = "Tabs"
selected_tab: Optional[str] = None
@ -341,7 +341,7 @@ class Tab(Component):
{% endprovide %}
"""
class Kwargs(NamedTuple):
class Kwargs:
header: str
disabled: bool = False
id: Optional[str] = None

View file

@ -393,21 +393,21 @@ Avoid needless errors with [type hints and runtime input validation](https://dja
To opt-in to input validation, define types for component's args, kwargs, slots:
```py
from typing import NamedTuple, Optional
from typing import Optional
from django.template import Context
from django_components import Component, Slot, SlotInput
class Button(Component):
class Args(NamedTuple):
class Args:
size: int
text: str
class Kwargs(NamedTuple):
class Kwargs:
variable: str
another: int
maybe_var: Optional[int] = None # May be omitted
class Slots(NamedTuple):
class Slots:
my_slot: Optional[SlotInput] = None
another_slot: SlotInput

View file

@ -1,5 +1,3 @@
from typing import NamedTuple
from django_components import Component, register
@ -15,7 +13,7 @@ class Calendar(Component):
js_file = "calendar/calendar.js"
# This component takes one parameter, a date string to show in the template
class Kwargs(NamedTuple):
class Kwargs:
date: str
def get_template_data(self, args, kwargs: Kwargs, slots, context):
@ -46,7 +44,7 @@ class CalendarRelative(Component):
js_file = "calendar.js"
# This component takes one parameter, a date string to show in the template
class Kwargs(NamedTuple):
class Kwargs:
date: str
def get_template_data(self, args, kwargs: Kwargs, slots, context):

View file

@ -1,4 +1,4 @@
from typing import Any, NamedTuple
from typing import Any
from django.http import HttpRequest, HttpResponse
@ -17,7 +17,7 @@ class CalendarNested(Component):
js_file = "calendar.js"
# This component takes one parameter, a date string to show in the template
class Kwargs(NamedTuple):
class Kwargs:
date: str
def get_template_data(self, args, kwargs: Kwargs, slots, context):

View file

@ -207,7 +207,7 @@ class CreateCommand(ComponentCommand):
js_file = "{js_filename}"
css_file = "{css_filename}"
class Kwargs(NamedTuple):
class Kwargs:
param: str
class Defaults:

View file

@ -1,6 +1,6 @@
# ruff: noqa: ARG002, N804, N805
import sys
from dataclasses import dataclass
from dataclasses import dataclass, is_dataclass
from inspect import signature
from types import MethodType
from typing import (
@ -78,7 +78,14 @@ from django_components.template import cache_component_template_file, prepare_co
from django_components.util.context import gen_context_processors_data, snapshot_context
from django_components.util.exception import component_error_message
from django_components.util.logger import trace_component_msg
from django_components.util.misc import default, gen_id, hash_comp_cls, is_generator, to_dict
from django_components.util.misc import (
convert_class_to_namedtuple,
default,
gen_id,
hash_comp_cls,
is_generator,
to_dict,
)
from django_components.util.template_tag import TagAttr
from django_components.util.weakref import cached_ref
@ -311,7 +318,7 @@ class ComponentVars(NamedTuple):
@register("table")
class Table(Component):
class Args(NamedTuple):
class Args:
page: int
per_page: int
@ -363,7 +370,7 @@ class ComponentVars(NamedTuple):
@register("table")
class Table(Component):
class Kwargs(NamedTuple):
class Kwargs:
page: int
per_page: int
@ -415,7 +422,7 @@ class ComponentVars(NamedTuple):
@register("table")
class Table(Component):
class Slots(NamedTuple):
class Slots:
footer: SlotInput
template = '''
@ -506,6 +513,34 @@ class ComponentMeta(ComponentMediaMeta):
attrs["template_file"] = attrs.pop("template_name")
attrs["template_name"] = ComponentTemplateNameDescriptor()
# Allow to define data classes (`Args`, `Kwargs`, `Slots`, `TemplateData`, `JsData`, `CssData`)
# without explicitly subclassing anything. In which case we make them into a subclass of `NamedTuple`.
# In other words:
# ```py
# class MyTable(Component):
# class Kwargs(NamedTuple):
# ...
# ```
# Can be simplified to:
# ```py
# class MyTable(Component):
# class Kwargs:
# ...
# ```
for data_class_name in ["Args", "Kwargs", "Slots", "TemplateData", "JsData", "CssData"]:
data_class = attrs.get(data_class_name)
# Not a class
if data_class is None or not isinstance(data_class, type):
continue
# Is dataclass
if is_dataclass(data_class):
continue
# Has base class(es)
has_parents = data_class.__bases__ != (object,)
if has_parents:
continue
attrs[data_class_name] = convert_class_to_namedtuple(data_class)
cls = cast("Type[Component]", super().__new__(mcs, name, bases, attrs))
# If the component defined `template_file`, then associate this Component class
@ -598,11 +633,10 @@ class Component(metaclass=ComponentMeta):
will be the instance of this class:
```py
from typing import NamedTuple
from django_components import Component
class Table(Component):
class Args(NamedTuple):
class Args:
color: str
size: int
@ -615,15 +649,6 @@ class Component(metaclass=ComponentMeta):
}
```
The constructor of this class MUST accept positional arguments:
```py
Args(*args)
```
As such, a good starting point is to set this field to a subclass of
[`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple).
Use `Args` to:
- Validate the input at runtime.
@ -640,6 +665,20 @@ class Component(metaclass=ComponentMeta):
)
```
If you do not specify any bases, the `Args` class will be automatically
converted to a `NamedTuple`:
`class Args:` -> `class Args(NamedTuple):`
If you explicitly set bases, the constructor of this class MUST accept positional arguments:
```py
Args(*args)
```
As such, a good starting point is to set this field to a subclass of
[`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple).
Read more on [Typing and validation](../../concepts/fundamentals/typing_and_validation).
"""
@ -654,11 +693,10 @@ class Component(metaclass=ComponentMeta):
will be the instance of this class:
```py
from typing import NamedTuple
from django_components import Component
class Table(Component):
class Kwargs(NamedTuple):
class Kwargs:
color: str
size: int
@ -671,16 +709,6 @@ class Component(metaclass=ComponentMeta):
}
```
The constructor of this class MUST accept keyword arguments:
```py
Kwargs(**kwargs)
```
As such, a good starting point is to set this field to a subclass of
[`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
or a [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass).
Use `Kwargs` to:
- Validate the input at runtime.
@ -697,6 +725,21 @@ class Component(metaclass=ComponentMeta):
)
```
If you do not specify any bases, the `Kwargs` class will be automatically
converted to a `NamedTuple`:
`class Kwargs:` -> `class Kwargs(NamedTuple):`
If you explicitly set bases, the constructor of this class MUST accept keyword arguments:
```py
Kwargs(**kwargs)
```
As such, a good starting point is to set this field to a subclass of
[`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
or a [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass).
Read more on [Typing and validation](../../concepts/fundamentals/typing_and_validation).
"""
@ -711,11 +754,10 @@ class Component(metaclass=ComponentMeta):
will be the instance of this class:
```py
from typing import NamedTuple
from django_components import Component, Slot, SlotInput
class Table(Component):
class Slots(NamedTuple):
class Slots:
header: SlotInput
footer: Slot
@ -728,16 +770,6 @@ class Component(metaclass=ComponentMeta):
}
```
The constructor of this class MUST accept keyword arguments:
```py
Slots(**slots)
```
As such, a good starting point is to set this field to a subclass of
[`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
or a [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass).
Use `Slots` to:
- Validate the input at runtime.
@ -757,6 +789,21 @@ class Component(metaclass=ComponentMeta):
)
```
If you do not specify any bases, the `Slots` class will be automatically
converted to a `NamedTuple`:
`class Slots:` -> `class Slots(NamedTuple):`
If you explicitly set bases, the constructor of this class MUST accept keyword arguments:
```py
Slots(**slots)
```
As such, a good starting point is to set this field to a subclass of
[`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
or a [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass).
Read more on [Typing and validation](../../concepts/fundamentals/typing_and_validation).
!!! info
@ -1081,18 +1128,17 @@ class Component(metaclass=ComponentMeta):
**Example:**
```py
from typing import NamedTuple
from django.template import Context
from django_components import Component, SlotInput
class MyComponent(Component):
class Args(NamedTuple):
class Args:
color: str
class Kwargs(NamedTuple):
class Kwargs:
size: int
class Slots(NamedTuple):
class Slots:
footer: SlotInput
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
@ -1123,7 +1169,7 @@ class Component(metaclass=ComponentMeta):
```py
class MyComponent(Component):
class TemplateData(NamedTuple):
class TemplateData:
color: str
size: int
@ -1156,22 +1202,22 @@ class Component(metaclass=ComponentMeta):
If set and not `None`, then this class will be instantiated with the dictionary returned from
[`get_template_data()`](../api#django_components.Component.get_template_data) to validate the data.
The constructor of this class MUST accept keyword arguments:
Use `TemplateData` to:
```py
TemplateData(**template_data)
```
- Validate the data returned from
[`get_template_data()`](../api#django_components.Component.get_template_data) at runtime.
- Set type hints for this data.
- Document the component data.
You can also return an instance of `TemplateData` directly from
[`get_template_data()`](../api#django_components.Component.get_template_data)
to get type hints:
```py
from typing import NamedTuple
from django_components import Component
class Table(Component):
class TemplateData(NamedTuple):
class TemplateData:
color: str
size: int
@ -1182,17 +1228,16 @@ class Component(metaclass=ComponentMeta):
)
```
The constructor of this class MUST accept keyword arguments:
```py
TemplateData(**template_data)
```
A good starting point is to set this field to a subclass of
[`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
or a [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass).
Use `TemplateData` to:
- Validate the data returned from
[`get_template_data()`](../api#django_components.Component.get_template_data) at runtime.
- Set type hints for this data.
- Document the component data.
Read more on [Typing and validation](../../concepts/fundamentals/typing_and_validation).
!!! info
@ -1376,13 +1421,13 @@ class Component(metaclass=ComponentMeta):
from django_components import Component, SlotInput
class MyComponent(Component):
class Args(NamedTuple):
class Args:
color: str
class Kwargs(NamedTuple):
class Kwargs:
size: int
class Slots(NamedTuple):
class Slots:
footer: SlotInput
def get_js_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
@ -1413,7 +1458,7 @@ class Component(metaclass=ComponentMeta):
```py
class MyComponent(Component):
class JsData(NamedTuple):
class JsData:
color: str
size: int
@ -1439,22 +1484,22 @@ class Component(metaclass=ComponentMeta):
If set and not `None`, then this class will be instantiated with the dictionary returned from
[`get_js_data()`](../api#django_components.Component.get_js_data) to validate the data.
The constructor of this class MUST accept keyword arguments:
Use `JsData` to:
```py
JsData(**js_data)
```
- Validate the data returned from
[`get_js_data()`](../api#django_components.Component.get_js_data) at runtime.
- Set type hints for this data.
- Document the component data.
You can also return an instance of `JsData` directly from
[`get_js_data()`](../api#django_components.Component.get_js_data)
to get type hints:
```py
from typing import NamedTuple
from django_components import Component
class Table(Component):
class JsData(NamedTuple):
class JsData(
color: str
size: int
@ -1465,17 +1510,16 @@ class Component(metaclass=ComponentMeta):
)
```
The constructor of this class MUST accept keyword arguments:
```py
JsData(**js_data)
```
A good starting point is to set this field to a subclass of
[`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
or a [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass).
Use `JsData` to:
- Validate the data returned from
[`get_js_data()`](../api#django_components.Component.get_js_data) at runtime.
- Set type hints for this data.
- Document the component data.
Read more on [Typing and validation](../../concepts/fundamentals/typing_and_validation).
!!! info
@ -1664,18 +1708,17 @@ class Component(metaclass=ComponentMeta):
**Example:**
```py
from typing import NamedTuple
from django.template import Context
from django_components import Component, SlotInput
class MyComponent(Component):
class Args(NamedTuple):
class Args:
color: str
class Kwargs(NamedTuple):
class Kwargs:
size: int
class Slots(NamedTuple):
class Slots:
footer: SlotInput
def get_css_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
@ -1705,7 +1748,7 @@ class Component(metaclass=ComponentMeta):
```py
class MyComponent(Component):
class CssData(NamedTuple):
class CssData:
color: str
size: int
@ -1731,22 +1774,22 @@ class Component(metaclass=ComponentMeta):
If set and not `None`, then this class will be instantiated with the dictionary returned from
[`get_css_data()`](../api#django_components.Component.get_css_data) to validate the data.
The constructor of this class MUST accept keyword arguments:
Use `CssData` to:
```py
CssData(**css_data)
```
- Validate the data returned from
[`get_css_data()`](../api#django_components.Component.get_css_data) at runtime.
- Set type hints for this data.
- Document the component data.
You can also return an instance of `CssData` directly from
[`get_css_data()`](../api#django_components.Component.get_css_data)
to get type hints:
```py
from typing import NamedTuple
from django_components import Component
class Table(Component):
class CssData(NamedTuple):
class CssData:
color: str
size: int
@ -1757,17 +1800,16 @@ class Component(metaclass=ComponentMeta):
)
```
The constructor of this class MUST accept keyword arguments:
```py
CssData(**css_data)
```
A good starting point is to set this field to a subclass of
[`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
or a [dataclass](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass).
Use `CssData` to:
- Validate the data returned from
[`get_css_data()`](../api#django_components.Component.get_css_data) at runtime.
- Set type hints for this data.
- Document the component data.
Read more on [Typing and validation](../../concepts/fundamentals/typing_and_validation).
!!! info
@ -2567,7 +2609,7 @@ class Component(metaclass=ComponentMeta):
from django_components import Component
class Table(Component):
class Args(NamedTuple):
class Args:
page: int
per_page: int
@ -2635,7 +2677,7 @@ class Component(metaclass=ComponentMeta):
from django_components import Component
class Table(Component):
class Kwargs(NamedTuple):
class Kwargs:
page: int
per_page: int
@ -2706,7 +2748,7 @@ class Component(metaclass=ComponentMeta):
from django_components import Component, Slot, SlotInput
class Table(Component):
class Slots(NamedTuple):
class Slots:
header: SlotInput
footer: SlotInput
@ -3314,19 +3356,19 @@ class Component(metaclass=ComponentMeta):
Read more on [Typing and validation](../../concepts/fundamentals/typing_and_validation).
```python
from typing import NamedTuple, Optional
from typing import Optional
from django_components import Component, Slot, SlotInput
# Define the component with the types
class Button(Component):
class Args(NamedTuple):
class Args:
name: str
class Kwargs(NamedTuple):
class Kwargs:
surname: str
age: int
class Slots(NamedTuple):
class Slots:
my_slot: Optional[SlotInput] = None
footer: SlotInput

View file

@ -1,4 +1,4 @@
from typing import NamedTuple, Optional, cast
from typing import Optional, cast
from django.template import Context, Template
from django.template.exceptions import TemplateSyntaxError
@ -115,10 +115,10 @@ class ErrorFallback(Component):
Remember to define the `content` slot as function, so it's evaluated from inside of `ErrorFallback`.
"""
class Kwargs(NamedTuple):
class Kwargs:
fallback: Optional[str] = None
class Slots(NamedTuple):
class Slots:
default: Optional[SlotInput] = None
content: Optional[SlotInput] = None
fallback: Optional[SlotInput] = None

View file

@ -1,3 +1,4 @@
import sys
from functools import wraps
from typing import (
TYPE_CHECKING,
@ -14,7 +15,7 @@ from typing import (
TypeVar,
Union,
)
from weakref import ref
from weakref import ReferenceType, ref
import django.urls
from django.template import Context, Origin, Template
@ -33,6 +34,13 @@ if TYPE_CHECKING:
from django_components.slots import Slot, SlotNode, SlotResult
# NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
ComponentInstanceRef = ReferenceType["Component"]
else:
ComponentInstanceRef = ReferenceType
TCallable = TypeVar("TCallable", bound=Callable)
TClass = TypeVar("TClass", bound=Type[Any])
@ -265,16 +273,27 @@ class ExtensionComponentConfig:
This attribute holds the owner [`Component`](./api.md#django_components.Component) instance
that this extension is defined on.
Some extensions like Storybook run outside of the component lifecycle,
so there is no component instance available when running extension's methods.
In such cases, this attribute will be `None`.
"""
component = self._component_ref()
component: Optional[Component] = None
if self._component_ref is not None:
component = self._component_ref()
if component is None:
raise RuntimeError("Component has been garbage collected")
return component
def __init__(self, component: "Component") -> None:
def __init__(self, component: "Optional[Component]") -> None:
# NOTE: Use weak reference to avoid a circular reference between the component instance
# and the extension class.
self._component_ref = ref(component)
if component is not None:
self._component_ref: Optional[ComponentInstanceRef] = ref(component)
else:
# NOTE: Some extensions like Storybook run outside of the component lifecycle,
# so there is no component instance available when running extension's methods.
self._component_ref = None
# TODO_v1 - Delete

View file

@ -16,6 +16,14 @@ class DependenciesExtension(ComponentExtension):
# Cache the component's JS and CSS scripts when the class is created, so that
# components' JS/CSS files are accessible even before having to render the component first.
#
# This is important for the scenario when the web server may restart in a middle of user
# session. In which case, if we did not cache the JS/CSS, then user may fail to retrieve
# JS/CSS of some component.
#
# Component JS/CSS is then also cached after each time a component is rendered.
# That way, if the component JS/CSS cache is smaller than the total number of
# components/assets, we add back the most-recent entries.
def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
cache_component_js(ctx.component_cls, force=True)
cache_component_css(ctx.component_cls, force=True)

View file

@ -394,7 +394,6 @@ slot content function.
**Example:**
```python
from typing import NamedTuple
from typing_extensions import TypedDict
from django_components import Component, SlotInput
@ -402,7 +401,7 @@ class TableFooterSlotData(TypedDict):
page_number: int
class Table(Component):
class Slots(NamedTuple):
class Slots:
header: SlotInput
footer: SlotInput[TableFooterSlotData]

View file

@ -1,8 +1,10 @@
import re
import sys
from collections import namedtuple
from dataclasses import asdict, is_dataclass
from hashlib import md5
from importlib import import_module
from inspect import getmembers
from itertools import chain
from types import ModuleType
from typing import (
@ -267,3 +269,56 @@ def format_as_ascii_table(
def is_generator(obj: Any) -> bool:
"""Check if an object is a generator with send method."""
return hasattr(obj, "send")
def convert_class_to_namedtuple(cls: Type[Any]) -> Type[Tuple[Any, ...]]:
# Construct fields for a NamedTuple. Unfortunately one can't further subclass the subclass of `NamedTuple`,
# so we need to construct a new class with the same fields.
# NamedTuple has:
# - Required fields, which are defined without values (annotations only)
# - Optional fields with defaults
# ```py
# class Z:
# b: str # Required, annotated
# a: int = None # Optional, annotated
# c = 1 # NOT A FIELD! Class var!
# ```
# Annotations are stored in `X.__annotations__`, while the defaults are regular class attributes
# NOTE: We ignore dunder methods
# NOTE 2: All fields with default values must come after fields without defaults.
field_names = list(cls.__annotations__.keys())
# Get default values from the original class and set them on the new NamedTuple class
field_names_set = set(field_names)
defaults = {}
class_attrs = {}
for name, value in getmembers(cls):
if name.startswith("__"):
continue
# Field default
if name in field_names_set:
defaults[name] = value
else:
# Class attribute
class_attrs[name] = value
# Figure out how many tuple fields have defaults. We need to know this
# because NamedTuple functional syntax uses the pattern where defaults
# are applied from the end.
# Final call then looks like this:
# `namedtuple("MyClass", ["a", "b", "c", "d"], defaults=[3, 4])`
# with defaults c=3 and d=4
num_fields_with_defaults = len(defaults)
if num_fields_with_defaults:
defaults_list = [defaults[name] for name in field_names[-num_fields_with_defaults:]]
else:
defaults_list = []
tuple_cls = namedtuple(cls.__name__, field_names, defaults=defaults_list) # type: ignore[misc] # noqa: PYI024
# `collections.namedtuple` doesn't allow to specify annotations, so we pass them afterwards
tuple_cls.__annotations__ = cls.__annotations__
# Likewise, `collections.namedtuple` doesn't allow to specify class vars
for name, value in class_attrs.items():
setattr(tuple_cls, name, value)
return tuple_cls

View file

@ -5,7 +5,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
import os
import re
from typing import Any, List, Literal, NamedTuple, Optional
from typing import Any, List, Literal, Optional
import pytest
from django.conf import settings
@ -591,23 +591,23 @@ class TestComponentRenderAPI:
class TestComponent(Component):
template = ""
class Args(NamedTuple):
class Args:
variable: int
another: str
class Kwargs(NamedTuple):
class Kwargs:
variable: str
another: int
class Slots(NamedTuple):
class Slots:
my_slot: SlotInput
def get_template_data(self, args, kwargs, slots, context):
nonlocal called
called = True
assert self.args == TestComponent.Args(123, "str")
assert self.kwargs == TestComponent.Kwargs(variable="test", another=1)
assert self.args == TestComponent.Args(123, "str") # type: ignore[call-arg]
assert self.kwargs == TestComponent.Kwargs(variable="test", another=1) # type: ignore[call-arg]
assert isinstance(self.slots, TestComponent.Slots)
assert isinstance(self.slots.my_slot, Slot)
assert self.slots.my_slot() == "MY_SLOT"
@ -789,15 +789,15 @@ class TestComponentTemplateVars:
def test_args_kwargs_slots__simple_typed(self):
class TestComponent(Component):
class Args(NamedTuple):
class Args:
variable: int
another: str
class Kwargs(NamedTuple):
class Kwargs:
variable: str
another: int
class Slots(NamedTuple):
class Slots:
my_slot: SlotInput
template: types.django_html = """
@ -898,15 +898,15 @@ class TestComponentTemplateVars:
"""
class TestComponent(Component):
class Args(NamedTuple):
class Args:
variable: int
another: str
class Kwargs(NamedTuple):
class Kwargs:
variable: str
another: int
class Slots(NamedTuple):
class Slots:
my_slot: SlotInput
template: types.django_html = """

View file

@ -1,5 +1,4 @@
import re
from typing import NamedTuple
import pytest
from django.template import Context, Template
@ -21,7 +20,7 @@ class TestDynamicComponent:
Variable: <strong>{{ variable }}</strong>
"""
class Kwargs(NamedTuple):
class Kwargs:
variable: str
variable2: str

View file

@ -1,5 +1,3 @@
from typing import NamedTuple
import pytest
from django.template import Context, Template
from django.template.exceptions import TemplateSyntaxError
@ -313,7 +311,7 @@ class TestErrorFallbackComponent:
def test_error_fallback_nested_inside_another(self, components_settings):
@register("broken")
class BrokenComponent(Component):
class Kwargs(NamedTuple):
class Kwargs:
msg: str
def on_render(self, context: Context, template: Template):

View file

@ -16,7 +16,7 @@ setup_test_config()
@djc_test
class TestComponentTyping:
def test_data_methods_input_typed(self):
def test_data_methods_input_typed_custom_classes(self):
template_called = False
js_called = False
css_called = False
@ -84,6 +84,74 @@ class TestComponentTyping:
assert js_called
assert css_called
def test_data_methods_input_typed_default_classes(self):
template_called = False
js_called = False
css_called = False
class ButtonFooterSlotData(TypedDict):
value: int
class Button(Component):
class Args:
arg1: str
arg2: str
class Kwargs:
name: str
age: int
maybe_var: Optional[int] = None
class Slots:
header: SlotInput
footer: Optional[Slot[ButtonFooterSlotData]]
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
nonlocal template_called
template_called = True
assert isinstance(args, Button.Args)
assert isinstance(kwargs, Button.Kwargs)
assert isinstance(slots, Button.Slots)
assert isinstance(context, Context)
def get_js_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
nonlocal js_called
js_called = True
assert isinstance(args, Button.Args)
assert isinstance(kwargs, Button.Kwargs)
assert isinstance(slots, Button.Slots)
assert isinstance(context, Context)
def get_css_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
nonlocal css_called
css_called = True
assert isinstance(args, Button.Args)
assert isinstance(kwargs, Button.Kwargs)
assert isinstance(slots, Button.Slots)
assert isinstance(context, Context)
template = "<button>Click me!</button>"
assert issubclass(Button.Args, tuple)
assert issubclass(Button.Kwargs, tuple)
assert issubclass(Button.Slots, tuple)
Button.render(
args=Button.Args(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
kwargs=Button.Kwargs(name="name", age=123), # type: ignore[call-arg]
slots=Button.Slots( # type: ignore[call-arg]
header="HEADER",
footer=Slot(lambda _ctx: "FOOTER"),
),
)
assert template_called
assert js_called
assert css_called
def test_data_methods_input_not_typed_by_default(self):
template_called = False
js_called = False
@ -132,7 +200,71 @@ class TestComponentTyping:
assert js_called
assert css_called
def test_data_methods_output_typed(self):
def test_data_methods_output_typed_default_classes(self):
template_called = False
js_called = False
css_called = False
class Button(Component):
class TemplateData:
data1: str
data2: int
class JsData:
js_data1: str
js_data2: int
class CssData:
css_data1: str
css_data2: int
def get_template_data(self, args, kwargs, slots, context):
nonlocal template_called
template_called = True
return {
"data1": "data1",
"data2": 123,
}
def get_js_data(self, args, kwargs, slots, context):
nonlocal js_called
js_called = True
return {
"js_data1": "js_data1",
"js_data2": 123,
}
def get_css_data(self, args, kwargs, slots, context):
nonlocal css_called
css_called = True
return {
"css_data1": "css_data1",
"css_data2": 123,
}
template = "<button>Click me!</button>"
assert issubclass(Button.TemplateData, tuple)
assert issubclass(Button.JsData, tuple)
assert issubclass(Button.CssData, tuple)
Button.render(
args=["arg1", "arg2"],
kwargs={"name": "name", "age": 123},
slots={
"header": "HEADER",
"footer": Slot(lambda _ctx: "FOOTER"),
},
)
assert template_called
assert js_called
assert css_called
def test_data_methods_output_typed_custom_classes(self):
template_called = False
js_called = False
css_called = False
@ -489,43 +621,57 @@ class TestComponentTyping:
)
def test_custom_args_class_raises_on_invalid(self):
class Parent:
pass
class Button(Component):
template = "Hello"
class Args:
class Args(Parent):
arg1: str
arg2: str
assert issubclass(Button.Args, Parent)
assert not issubclass(Button.Args, tuple)
with pytest.raises(TypeError, match=re.escape("Args() takes no arguments")):
Button.render(
args=Button.Args(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
)
class Parent2:
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
class Button2(Component):
template = "Hello"
class Args:
class Args(Parent2):
arg1: str
arg2: str
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
assert not issubclass(Button2.Args, tuple)
assert issubclass(Button2.Args, Parent2)
with pytest.raises(TypeError, match=re.escape("'Args' object is not iterable")):
Button2.render(
args=Button2.Args(arg1="arg1", arg2="arg2"),
)
class Parent3:
def __iter__(self):
return iter([self.arg1, self.arg2]) # type: ignore[attr-defined]
class Button3(Component):
template = "Hello"
class Args:
class Args(Parent3):
arg1: str
arg2: str
def __iter__(self):
return iter([self.arg1, self.arg2])
assert not issubclass(Button3.Args, tuple)
assert issubclass(Button3.Args, Parent3)
with pytest.raises(TypeError, match=re.escape("Args() takes no arguments")):
Button3.render(
@ -533,62 +679,77 @@ class TestComponentTyping:
)
def test_custom_args_class_custom(self):
class Parent:
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
def __iter__(self):
return iter([self.arg1, self.arg2])
class Button(Component):
template = "Hello"
class Args:
class Args(Parent):
arg1: str
arg2: str
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
def __iter__(self):
return iter([self.arg1, self.arg2])
Button.render(
args=Button.Args(arg1="arg1", arg2="arg2"),
)
def test_custom_kwargs_class_raises_on_invalid(self):
class Parent:
pass
class Button(Component):
template = "Hello"
class Kwargs:
class Kwargs(Parent):
arg1: str
arg2: str
assert not issubclass(Button.Kwargs, tuple)
assert issubclass(Button.Kwargs, Parent)
with pytest.raises(TypeError, match=re.escape("Kwargs() takes no arguments")):
Button.render(
kwargs=Button.Kwargs(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
)
class Parent2:
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
class Button2(Component):
template = "Hello"
class Kwargs:
class Kwargs(Parent2):
arg1: str
arg2: str
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
assert not issubclass(Button2.Kwargs, tuple)
assert issubclass(Button2.Kwargs, Parent2)
with pytest.raises(TypeError, match=re.escape("'Kwargs' object is not iterable")):
Button2.render(
kwargs=Button2.Kwargs(arg1="arg1", arg2="arg2"),
)
class Parent3:
def _asdict(self):
return {"arg1": self.arg1, "arg2": self.arg2} # type: ignore[attr-defined]
class Button3(Component):
template = "Hello"
class Kwargs:
class Kwargs(Parent3):
arg1: str
arg2: str
def _asdict(self):
return {"arg1": self.arg1, "arg2": self.arg2}
assert not issubclass(Button3.Kwargs, tuple)
assert issubclass(Button3.Kwargs, Parent3)
with pytest.raises(TypeError, match=re.escape("Kwargs() takes no arguments")):
Button3.render(
@ -596,19 +757,23 @@ class TestComponentTyping:
)
def test_custom_kwargs_class_custom(self):
class Parent:
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
def _asdict(self):
return {"arg1": self.arg1, "arg2": self.arg2}
class Button(Component):
template = "Hello"
class Kwargs:
class Kwargs(Parent):
arg1: str
arg2: str
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
def _asdict(self):
return {"arg1": self.arg1, "arg2": self.arg2}
assert not issubclass(Button.Kwargs, tuple)
assert issubclass(Button.Kwargs, Parent)
Button.render(
kwargs=Button.Kwargs(arg1="arg1", arg2="arg2"),
@ -618,14 +783,14 @@ class TestComponentTyping:
class Button(Component):
template = "Hello"
class Args(NamedTuple):
class Args:
size: int
class Kwargs(NamedTuple):
class Kwargs:
color: str
class ButtonExtra(Button):
class Args(NamedTuple):
class Args:
name: str
size: int
@ -635,6 +800,6 @@ class TestComponentTyping:
assert ButtonExtra.Kwargs is Button.Kwargs
ButtonExtra.render(
args=ButtonExtra.Args(name="John", size=30),
kwargs=ButtonExtra.Kwargs(color="red"),
args=ButtonExtra.Args(name="John", size=30), # type: ignore[call-arg]
kwargs=ButtonExtra.Kwargs(color="red"), # type: ignore[call-arg]
)

View file

@ -242,6 +242,11 @@ class TestExtensions:
del TestAccessComp
gc.collect()
@djc_test(components_settings={"extensions": [DummyExtension]})
def test_instantiate_ext_component_config_none(self):
config = DummyExtension.ComponentConfig(None)
assert isinstance(config, DummyExtension.ComponentConfig)
def test_raises_on_extension_name_conflict(self):
@djc_test(components_settings={"extensions": [RenderExtension]})
def inner():

View file

@ -8,8 +8,6 @@ This file can be deleted after Django 5.2 reached end of life.
See https://github.com/django-components/django-components/issues/1323#issuecomment-3163478287.
"""
from typing import NamedTuple
import pytest
from django.http import HttpRequest
from django.shortcuts import render
@ -53,7 +51,7 @@ class TestTemplatePartialsIntegration:
})()
""" # noqa: E501
class Kwargs(NamedTuple):
class Kwargs:
date: str
def get_template_data(self, args, kwargs: Kwargs, slots, context):