refactor: change component typing from generics to class attributes (#1138)

This commit is contained in:
Juro Oravec 2025-04-20 22:05:29 +02:00 committed by GitHub
parent 912d8e8074
commit b49002b545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2451 additions and 610 deletions

View file

@ -1,5 +1,123 @@
# Release notes
## 🚨📢 v0.140.0
#### 🚨📢 BREAKING CHANGES
- Component typing no longer uses generics. Instead, the types are now defined as class attributes of the component class.
Before:
```py
Args = Tuple[float, str]
class Button(Component[Args]):
pass
```
After:
```py
class Button(Component):
class Args(NamedTuple):
size: float
text: str
```
See [Migrating from generics to class attributes](https://django-components.github.io/django-components/latest/concepts/advanced/typing_and_validation/#migrating-from-generics-to-class-attributes) for more info.
- The interface of the not-yet-released `get_js_data()` and `get_css_data()` methods has changed to
match `get_template_data()`.
```py
def get_js_data(self, *args, **kwargs):
def get_css_data(self, *args, **kwargs):
```
to:
```py
def get_js_data(self, args, kwargs, slots, context):
def get_css_data(self, args, kwargs, slots, context):
```
- Removed `EmptyTuple` and `EmptyDict` types. Instead, there is now a single `Empty` type.
```py
from django_components import Component, Empty
class Button(Component):
template = "Hello"
Args = Empty
Kwargs = Empty
```
- The order of arguments in `Component.render_to_response()` has changed
to match that of `Component.render()`.
Please ensure that you pass the parameters as kwargs, not as positional arguments,
to avoid breaking changes.
The signature changed, moving the `args` and `kwargs` parameters to 2nd and 3rd position.
Before:
```py
def render_to_response(
cls,
context: Optional[Union[Dict[str, Any], Context]] = None,
slots: Optional[SlotsType] = None,
escape_slots_content: bool = True,
args: Optional[ArgsType] = None,
kwargs: Optional[KwargsType] = None,
type: RenderType = "document",
request: Optional[HttpRequest] = None,
*response_args: Any,
**response_kwargs: Any,
) -> HttpResponse:
```
After:
```py
def render_to_response(
context: Optional[Union[Dict[str, Any], Context]] = None,
args: Optional[Tuple[Any, ...]] = None,
kwargs: Optional[Mapping] = None,
slots: Optional[Mapping] = None,
escape_slots_content: bool = True,
type: RenderType = "document",
render_dependencies: bool = True,
request: Optional[HttpRequest] = None,
*response_args: Any,
**response_kwargs: Any,
) -> HttpResponse:
```
#### 🚨📢 Deprecation
- `get_context_data()` is now deprecated. Use `get_template_data()` instead.
`get_template_data()` behaves the same way, but has a different function signature
to accept also slots and context.
Since `get_context_data()` is widely used, it will remain available until v2.
- `SlotContent` was renamed to `SlotInput`. The old name is deprecated and will be removed in v1.
#### Feat
- Input validation is now part of the render process.
When you specify the input types (such as `Component.Args`, `Component.Kwargs`, etc),
the actual inputs to data methods (`Component.get_template_data()`, etc) will be instances of the types you specified.
This practically brings back input validation, because the instantiation of the types
will raise an error if the inputs are not valid.
Read more on [Typing and validation](https://django-components.github.io/django-components/latest/concepts/advanced/typing_and_validation/)
## v0.139.1
#### Fix

View file

@ -389,52 +389,53 @@ class Header(Component):
}
```
### Static type hints
### Input validation and static type hints
Components API is fully typed, and supports [static type hints](https://django-components.github.io/django-components/latest/concepts/advanced/typing_and_validation/).
Avoid needless errors with [type hints and runtime input validation](https://django-components.github.io/django-components/latest/concepts/advanced/typing_and_validation/).
To opt-in to static type hints, define types for component's args, kwargs, slots, and more:
To opt-in to input validation, define types for component's args, kwargs, slots, and more:
```py
from typing import NotRequired, Tuple, TypedDict, SlotContent, Slot
from typing import NamedTuple, Optional
from django.template import Context
from django_components import Component, Slot, SlotInput
from django_components import Component
class Button(Component):
class Args(NamedTuple):
size: int
text: str
ButtonArgs = Tuple[int, str]
class Kwargs(NamedTuple):
variable: str
another: int
maybe_var: Optional[int] = None # May be omitted
class ButtonKwargs(TypedDict):
variable: str
another: int
maybe_var: NotRequired[int] # May be omitted
class Slots(NamedTuple):
my_slot: Optional[SlotInput] = None
another_slot: SlotInput
class ButtonSlots(TypedDict):
# Use `Slot` for slot functions.
my_slot: NotRequired[Slot]
# Use `SlotContent` when you want to allow either `Slot` instance or plain string
another_slot: SlotContent
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
self.input.args[0] # int
self.input.kwargs["variable"] # str
self.input.slots["my_slot"] # Slot[MySlotData]
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
args.size # int
kwargs.variable # str
slots.my_slot # Slot[MySlotData]
```
When you then call
To have type hints when calling
[`Button.render()`](https://django-components.github.io/django-components/latest/reference/api/#django_components.Component.render) or
[`Button.render_to_response()`](https://django-components.github.io/django-components/latest/reference/api/#django_components.Component.render_to_response),
you will get type hints:
wrap the inputs in their respective `Args`, `Kwargs`, and `Slots` classes:
```py
Button.render(
# Error: First arg must be `int`, got `float`
args=(1.25, "abc"),
args=Button.Args(
size=1.25,
text="abc",
),
# Error: Key "another" is missing
kwargs={
"variable": "text",
},
kwargs=Button.Kwargs(
variable="text",
),
)
```

View file

@ -101,35 +101,30 @@ For live examples, see the [Community examples](../../overview/community.md#comm
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 Dict, NotRequired, Optional, Tuple, TypedDict
from django_components import Component, SlotContent, register, types
from typing import NamedTuple, Optional
from django_components import Component, SlotInput, register, types
from myapp.templatetags.mytags import comp_registry
# Define the types
class EmptyDict(TypedDict):
pass
type MyMenuArgs = Tuple[int, str]
class MyMenuSlots(TypedDict):
default: NotRequired[Optional[SlotContent[EmptyDict]]]
class MyMenuProps(TypedDict):
vertical: NotRequired[bool]
klass: NotRequired[str]
style: NotRequired[str]
# Define the component
# NOTE: Don't forget to set the `registry`!
@register("my_menu", registry=comp_registry)
class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots]):
def get_context_data(
self,
*args,
attrs: Optional[Dict] = None,
):
class MyMenu(Component):
# Define the types
class Args(NamedTuple):
size: int
text: str
class Kwargs(NamedTuple):
vertical: Optional[bool] = None
klass: Optional[str] = None
style: Optional[str] = None
class Slots(NamedTuple):
default: Optional[SlotInput] = None
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
attrs = ...
return {
"attrs": attrs,
}

View file

@ -1,251 +1,470 @@
## Adding type hints with Generics
## Typing overview
_New in version 0.92_
<!-- TODO_V1 - REMOVE IN v1 -->
The [`Component`](../../../reference/api#django_components.Component) class optionally accepts type parameters
that allow you to specify the types of args, kwargs, slots, and data.
!!! warning
Use this to add type hints to your components, or to validate component inputs.
In versions 0.92 to 0.139 (inclusive), the component typing was specified through generics.
Since v0.140, the types must be specified as class attributes of the Component class - `Args`, `Kwargs`, `Slots`, `TemplateData`, `JsData`, and `CssData`.
See [Migrating from generics to class attributes](#migrating-from-generics-to-class-attributes) for more info.
!!! warning
Input validation was NOT part of Django Components between versions 0.136 and 0.139 (inclusive).
The [`Component`](../../../reference/api#django_components.Component) class optionally accepts class attributes
that allow you to define the types of args, kwargs, slots, as well as the data returned from the data methods.
Use this to add type hints to your components, to validate the inputs at runtime, and to document them.
```py
from django_components import Component
from typing import NamedTuple, Optional
from django.template import Context
from django_components import Component, SlotInput
ButtonType = Component[Args, Kwargs, Slots, TemplateData, JsData, CssData]
class Button(Component):
class Args(NamedTuple):
size: int
text: str
class Button(ButtonType):
template_file = "button.html"
class Kwargs(NamedTuple):
variable: str
maybe_var: Optional[int] = None # May be omitted
def get_context_data(self, *args, **kwargs):
class Slots(NamedTuple):
my_slot: Optional[SlotInput] = None
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
...
template_file = "button.html"
```
The generic parameters are:
The class attributes are:
- `Args` - Positional arguments, must be a `Tuple` or `Any`
- `Kwargs` - Keyword arguments, must be a `TypedDict` or `Any`
- `Slots` - Slots, must be a `TypedDict` or `Any`
- `TemplateData` - Data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), must be a `TypedDict` or `Any`
- `JsData` - Data returned from [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), must be a `TypedDict` or `Any`
- `CssData` - Data returned from [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data), must be a `TypedDict` or `Any`
- [`Args`](../../../reference/api#django_components.Component.Args) - Type for positional arguments.
- [`Kwargs`](../../../reference/api#django_components.Component.Kwargs) - Type for keyword arguments.
- [`Slots`](../../../reference/api#django_components.Component.Slots) - Type for slots.
- [`TemplateData`](../../../reference/api#django_components.Component.TemplateData) - Type for data returned from [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data).
- [`JsData`](../../../reference/api#django_components.Component.JsData) - Type for data returned from [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data).
- [`CssData`](../../../reference/api#django_components.Component.CssData) - Type for data returned from [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data).
You can specify as many or as few of the parameters as you want. The rest will default to `Any`.
All of these are valid:
```py
ButtonType = Component[Args, Kwargs, Slots, TemplateData, JsData, CssData]
ButtonType = Component[Args, Kwargs, Slots, TemplateData, JsData]
ButtonType = Component[Args, Kwargs, Slots, TemplateData]
ButtonType = Component[Args, Kwargs, Slots]
ButtonType = Component[Args, Kwargs]
ButtonType = Component[Args]
```
!!! info
Setting a type parameter to `Any` will disable typing and validation for that part.
You can specify as many or as few of these as you want, the rest will default to `None`.
## Typing inputs
You can use the first 3 parameters to type inputs.
You can use [`Component.Args`](../../../reference/api#django_components.Component.Args),
[`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
([`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))
will be instances of these classes.
This way, each component can have runtime validation of the inputs:
- When you use [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
or [`@dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass),
instantiating these classes will check ONLY for the presence of the attributes.
- When you use [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/),
instantiating these classes will check for the presence AND type of the attributes.
If you omit the [`Args`](../../../reference/api#django_components.Component.Args),
[`Kwargs`](../../../reference/api#django_components.Component.Kwargs), or
[`Slots`](../../../reference/api#django_components.Component.Slots) classes,
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 NotRequired, Tuple, TypedDict
from django_components import Component, Slot, SlotContent
###########################################
# 1. Define the types
###########################################
# Positional inputs
ButtonArgs = Tuple[str, ...]
# Keyword inputs
class ButtonKwargs(TypedDict):
name: str
age: int
maybe_var: NotRequired[int] # May be ommited
from typing_extensions import NamedTuple, TypedDict
from django.template import Context
from django_components import Component, Slot, SlotInput
# The data available to the `footer` scoped slot
class ButtonFooterSlotData(TypedDict):
value: int
# Slots
class ButtonSlots(TypedDict):
# Use `SlotContent` when you want to allow either `Slot` instance or plain string
header: SlotContent
# Use `Slot` for slot functions.
# The generic specifies the data available to the slot function
footer: NotRequired[Slot[ButtonFooterSlotData]]
# Define the component with the types
class Button(Component):
class Args(NamedTuple):
name: str
###########################################
# 2. Define the component with those types
###########################################
class Kwargs(NamedTuple):
surname: str
age: int
maybe_var: Optional[int] = None # May be ommited
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
class Slots(NamedTuple):
# 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
# Use `Slot` to allow ONLY `Slot` instances.
# The generic is optional, and it specifies the data available
# to the slot function.
footer: Slot[ButtonFooterSlotData]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
...
```
# Add type hints to the data method
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
# The parameters are instances of the classes we defined
assert isinstance(args, Button.Args)
assert isinstance(kwargs, Button.Kwargs)
assert isinstance(slots, Button.Slots)
When you then call
[`Component.render`](../../../reference/api#django_components.Component.render)
or [`Component.render_to_response`](../../../reference/api#django_components.Component.render_to_response),
you will get type hints:
args.name # str
kwargs.age # int
slots.footer # Slot[ButtonFooterSlotData]
```python
# Add type hints to the render call
Button.render(
# ERROR: Expects a string
args=(123,),
kwargs={
"name": "John",
# ERROR: Expects an integer
"age": "invalid",
},
slots={
"header": "...",
"footer": lambda ctx, slot_data, slot_ref: slot_data.value + 1,
# ERROR: Unexpected key "foo"
"foo": "invalid",
},
args=Button.Args(
name="John",
),
kwargs=Button.Kwargs(
surname="Doe",
age=30,
),
slots=Button.Slots(
footer=Button.Slot(lambda ctx, slot_data, slot_ref: slot_data.value + 1),
),
)
```
If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any) or omit them.
If you don't want to validate some parts, set them to `None` or omit them.
The following will validate only the keyword inputs:
```python
ButtonType = Component[Any, ButtonKwargs]
class Button(Component):
# We could also omit these
Args = None
Slots = None
class Button(ButtonType):
...
class Kwargs(NamedTuple):
name: str
age: int
# Only `kwargs` is instantiated. `args` and `slots` are not.
def get_template_data(self, args, kwargs: Kwargs, slots, context: Context):
assert isinstance(args, list)
assert isinstance(slots, dict)
assert isinstance(kwargs, Button.Kwargs)
args[0] # str
slots["footer"] # Slot[ButtonFooterSlotData]
kwargs.age # int
```
!!! info
Components can receive slots as strings, functions, or instances of [`Slot`](../../../reference/api#django_components.Slot).
Internally these are all normalized to instances of [`Slot`](../../../reference/api#django_components.Slot).
Therefore, the `slots` dictionary available in data methods (like
[`get_template_data()`](../../../reference/api#django_components.Component.get_template_data))
will always be a dictionary of [`Slot`](../../../reference/api#django_components.Slot) instances.
To correctly type this dictionary, you should set the fields of `Slots` to
[`Slot`](../../../reference/api#django_components.Slot) or [`SlotInput`](../../../reference/api#django_components.SlotInput):
[`SlotInput`](../../../reference/api#django_components.SlotInput) is a union of `Slot`, string, and function types.
## Typing data
You can use the last 3 parameters to type the data returned from [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), and [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data).
You can use [`Component.TemplateData`](../../../reference/api#django_components.Component.TemplateData),
[`Component.JsData`](../../../reference/api#django_components.Component.JsData),
and [`Component.CssData`](../../../reference/api#django_components.Component.CssData) to type the data returned from [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data), [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), and [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data).
Use this together with the [Pydantic integration](#runtime-input-validation-with-types) to have runtime validation of the data.
When you set these classes, at render time they will be instantiated with the data returned from these methods.
This way, each component can have runtime validation of the returned data:
- When you use [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
or [`@dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass),
instantiating these classes will check ONLY for the presence of the attributes.
- When you use [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/),
instantiating these classes will check for the presence AND type of the attributes.
If you omit the [`TemplateData`](../../../reference/api#django_components.Component.TemplateData),
[`JsData`](../../../reference/api#django_components.Component.JsData), or
[`CssData`](../../../reference/api#django_components.Component.CssData) classes,
or set them to `None`, the validation and instantiation will be skipped.
```python
from typing_extensions import NotRequired, Tuple, TypedDict
from django_components import Component, Slot, SlotContent
from typing import NamedTuple
from django_components import Component
###########################################
# 1. Define the types
###########################################
class Button(Component):
class TemplateData(NamedTuple):
data1: str
data2: int
# Data returned from `get_context_data`
class ButtonData(TypedDict):
data1: str
data2: int
class JsData(NamedTuple):
js_data1: str
js_data2: int
# Data returned from `get_js_data`
class ButtonJsData(TypedDict):
js_data1: str
js_data2: int
class CssData(NamedTuple):
css_data1: str
css_data2: int
# Data returned from `get_css_data`
class ButtonCssData(TypedDict):
css_data1: str
css_data2: int
def get_template_data(self, args, kwargs, slots, context):
return {
"data1": "...",
"data2": 123,
}
###########################################
# 2. Define the component with those types
###########################################
def get_js_data(self, args, kwargs, slots, context):
return {
"js_data1": "...",
"js_data2": 123,
}
ButtonType = Component[Any, Any, Any, ButtonData, ButtonJsData, ButtonCssData]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
...
def get_js_data(self, *args, **kwargs):
...
def get_css_data(self, *args, **kwargs):
...
def get_css_data(self, args, kwargs, slots, context):
return {
"css_data1": "...",
"css_data2": 123,
}
```
When you then call
[`Component.render`](../../../reference/api#django_components.Component.render)
or [`Component.render_to_response`](../../../reference/api#django_components.Component.render_to_response),
the component will raise an error if the data is not valid.
If you don't want to validate some parts, set them to [`Any`](https://docs.python.org/3/library/typing.html#typing.Any).
The following will validate only the data returned from `get_js_data`:
For each data method, you can either return a plain dictionary with the data, or an instance of the respective data class directly.
```python
ButtonType = Component[Any, Any, Any, Any, ButtonJsData]
from typing import NamedTuple
from django_components import Component
class Button(ButtonType):
...
class Button(Component):
class TemplateData(NamedTuple):
data1: str
data2: int
class JsData(NamedTuple):
js_data1: str
js_data2: int
class CssData(NamedTuple):
css_data1: str
css_data2: int
def get_template_data(self, args, kwargs, slots, context):
return Button.TemplateData(
data1="...",
data2=123,
)
def get_js_data(self, args, kwargs, slots, context):
return Button.JsData(
js_data1="...",
js_data2=123,
)
def get_css_data(self, args, kwargs, slots, context):
return Button.CssData(
css_data1="...",
css_data2=123,
)
```
## 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),
[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
represents a list of positional arguments. It must meet two conditions:
1. The constructor for the `Args` class must accept positional arguments.
```py
Args(*args)
```
2. The `Args` instance must be convertable to a list.
```py
list(Args(1, 2, 3))
```
To implement the conversion to a list, you can implement the `__iter__()` method:
```py
class MyClass:
def __init__(self):
self.x = 1
self.y = 2
def __iter__(self):
return iter([('x', self.x), ('y', self.y)])
```
### Dictionary classes
On the other hand, other types
([`Kwargs`](../../../reference/api#django_components.Component.Kwargs),
[`Slots`](../../../reference/api#django_components.Component.Slots),
[`TemplateData`](../../../reference/api#django_components.Component.TemplateData),
[`JsData`](../../../reference/api#django_components.Component.JsData),
and [`CssData`](../../../reference/api#django_components.Component.CssData))
represent dictionaries. They must meet these two conditions:
1. The constructor must accept keyword arguments.
```py
Kwargs(**kwargs)
Slots(**slots)
```
2. The instance must be convertable to a dictionary.
```py
dict(Kwargs(a=1, b=2))
dict(Slots(a=1, b=2))
```
To implement the conversion to a dictionary, you can implement either:
1. `_asdict()` method
```py
class MyClass:
def __init__(self):
self.x = 1
self.y = 2
def _asdict(self):
return {'x': self.x, 'y': self.y}
```
2. Or make the class dict-like with `__iter__()` and `__getitem__()`
```py
class MyClass:
def __init__(self):
self.x = 1
self.y = 2
def __iter__(self):
return iter([('x', self.x), ('y', self.y)])
def __getitem__(self, key):
return getattr(self, key)
```
## Passing variadic args and kwargs
You may have a function that accepts a variable number of args or kwargs:
You may have a component that accepts any number of args or kwargs.
```py
def get_context_data(self, *args, **kwargs):
...
```
This is not supported with the typed components.
However, this cannot be described with the current Python's typing system (as of v0.140).
As a workaround:
- For a variable number of positional arguments (`*args`), set a positional argument that accepts a list of values:
```py
# Tuple of one member of list of strings
Args = Tuple[List[str]]
class Table(Component):
class Args(NamedTuple):
args: List[str]
Table.render(
args=Table.Args(args=["a", "b", "c"]),
)
```
- For a variable number of keyword arguments (`**kwargs`), set a keyword argument that accepts a dictionary of values:
```py
class Kwargs(TypedDict):
variable: str
another: int
# Pass any extra keys under `extra`
extra: Dict[str, any]
class Table(Component):
class Kwargs(NamedTuple):
variable: str
another: int
# Pass any extra keys under `extra`
extra: Dict[str, any]
Table.render(
kwargs=Table.Kwargs(
variable="a",
another=1,
extra={"foo": "bar"},
),
)
```
## Handling no args or no kwargs
To declare that a component accepts no args, kwargs, etc, you can use the
[`EmptyTuple`](../../../reference/api#django_components.EmptyTuple) and
[`EmptyDict`](../../../reference/api#django_components.EmptyDict) types:
To declare that a component accepts no args, kwargs, etc, define the types with no attributes using the `pass` keyword:
```py
from django_components import Component, EmptyDict, EmptyTuple
from typing import NamedTuple
from django_components import Component
class Button(Component[EmptyTuple, EmptyDict, EmptyDict, EmptyDict, EmptyDict, EmptyDict]):
...
class Button(Component):
class Args(NamedTuple):
pass
class Kwargs(NamedTuple):
pass
class Slots(NamedTuple):
pass
```
## Runtime input validation with types
This can get repetitive, so we added a [`Empty`](../../../reference/api#django_components.Empty) type to make it easier:
!!! warning
```py
from django_components import Component, Empty
Input validation was part of Django Components from version 0.96 to 0.135.
class Button(Component):
Args = Empty
Kwargs = Empty
Slots = Empty
```
Since v0.136, input validation is available as a separate extension.
## Runtime type validation
By defualt, when you add types to your component, this will only set up static type hints,
but it will not validate the inputs.
When you add types to your component, and implement
them as [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) or [`dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), the validation will check only for the presence of the attributes.
To enable input validation, you need to install the [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic) extension:
So this will not catch if you pass a string to an `int` attribute.
To enable runtime type validation, you need to use [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/), and install the [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic) extension.
The `djc-ext-pydantic` extension ensures compatibility between django-components' classes such as `Component`, or `Slot` and Pydantic models.
First install the extension:
```bash
pip install djc-ext-pydantic
```
And add the extension to your project:
And then add the extension to your project:
```py
COMPONENTS = {
@ -255,22 +474,126 @@ COMPONENTS = {
}
```
`djc-ext-pydantic` integrates [Pydantic](https://pydantic.dev/) for input and data validation. It uses the types defined on the component's class to validate inputs of Django components.
<!-- TODO_V1 - REMOVE IN v1 -->
## Usage for Python <3.11
## Migrating from generics to class attributes
On Python 3.8-3.10, use `typing_extensions`
In versions 0.92 to 0.139 (inclusive), the component typing was specified through generics.
Since v0.140, the types must be specified as class attributes of the [Component](../../../reference/api#django_components.Component) class -
[`Args`](../../../reference/api#django_components.Component.Args),
[`Kwargs`](../../../reference/api#django_components.Component.Kwargs),
[`Slots`](../../../reference/api#django_components.Component.Slots),
[`TemplateData`](../../../reference/api#django_components.Component.TemplateData),
[`JsData`](../../../reference/api#django_components.Component.JsData),
and [`CssData`](../../../reference/api#django_components.Component.CssData).
This change was necessary to make it possible to subclass components. Subclassing with generics was otherwise too complicated. Read the discussion [here](https://github.com/django-components/django-components/issues/1122).
Because of this change, the [`Component.render()`](../../../reference/api#django_components.Component.render)
method is no longer typed.
To type-check the inputs, you should wrap the inputs in [`Component.Args`](../../../reference/api#django_components.Component.Args),
[`Component.Kwargs`](../../../reference/api#django_components.Component.Kwargs),
[`Component.Slots`](../../../reference/api#django_components.Component.Slots), etc.
For example, if you had a component like this:
```py
from typing_extensions import TypedDict, NotRequired
from typing import NotRequired, Tuple, TypedDict
from django_components import Component, Slot, SlotInput
ButtonArgs = Tuple[int, str]
class ButtonKwargs(TypedDict):
variable: str
another: int
maybe_var: NotRequired[int] # May be omitted
class ButtonSlots(TypedDict):
# Use `SlotInput` to allow slots to be given as `Slot` instance,
# plain string, or a function that returns a string.
my_slot: NotRequired[SlotInput]
# Use `Slot` to allow ONLY `Slot` instances.
another_slot: Slot
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
self.input.args[0] # int
self.input.kwargs["variable"] # str
self.input.slots["my_slot"] # Slot[MySlotData]
Button.render(
args=(1, "hello"),
kwargs={
"variable": "world",
"another": 123,
},
slots={
"my_slot": "...",
"another_slot": Slot(lambda: ...),
},
)
```
Additionally on Python 3.8-3.9, also import `annotations`:
The steps to migrate are:
1. Convert all the types (`ButtonArgs`, `ButtonKwargs`, `ButtonSlots`) to subclasses
of [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple).
2. Move these types inside the Component class (`Button`), and rename them to `Args`, `Kwargs`, and `Slots`.
3. If you defined typing for the data methods (like [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data)), move them inside the Component class, and rename them to `TemplateData`, `JsData`, and `CssData`.
4. Remove the `Component` generic.
5. If you accessed the `args`, `kwargs`, or `slots` attributes via
[`self.input`](../../../reference/api#django_components.Component.input), you will need to add the type hints yourself, because [`self.input`](../../../reference/api#django_components.Component.input) is no longer typed.
Otherwise, you may use [`Component.get_template_data()`](../../../reference/api#django_components.Component.get_template_data) instead of [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), as `get_template_data()` receives `args`, `kwargs`, `slots` and `context` as arguments. You will still need to add the type hints yourself.
6. Lastly, you will need to update the [`Component.render()`](../../../reference/api#django_components.Component.render)
calls to wrap the inputs in [`Component.Args`](../../../reference/api#django_components.Component.Args), [`Component.Kwargs`](../../../reference/api#django_components.Component.Kwargs), and [`Component.Slots`](../../../reference/api#django_components.Component.Slots), to manually add type hints.
Thus, the code above will become:
```py
from __future__ import annotations
from typing import NamedTuple, 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):
size: int
text: str
class Kwargs(NamedTuple):
variable: str
another: int
maybe_var: Optional[int] = None # May be omitted
class Slots(NamedTuple):
# 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
# Use `Slot` to allow ONLY `Slot` instances.
another_slot: Slot
# The args, kwargs, slots are instances of the component's Args, Kwargs, and Slots classes
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
args.size # int
kwargs.variable # str
slots.my_slot # Slot[MySlotData]
Button.render(
# Wrap the inputs in the component's Args, Kwargs, and Slots classes
args=Button.Args(1, "hello"),
kwargs=Button.Kwargs(
variable="world",
another=123,
),
slots=Button.Slots(
my_slot="...",
another_slot=Slot(lambda: ...),
),
)
```
Moreover, on 3.10 and less, you may not be able to use `NotRequired`, and instead you will need to mark either all keys are required, or all keys as optional, using TypeDict's `total` kwarg.
[See PEP-655](https://peps.python.org/pep-0655) for more info.

View file

@ -15,7 +15,7 @@ Example:
class Table(Component):
def get_context_data(self, *args, **attrs):
# Access component's ID
assert self.id == "djc1A2b3c"
assert self.id == "c1A2b3c"
# Access component's inputs, slots and context
assert self.input.args == (123, "str")
@ -66,7 +66,7 @@ If you need to expand this limit, please open an issue on GitHub.
class Table(Component):
def get_context_data(self, *args, **attrs):
# Access component's ID
assert self.id == "djc1A2b3c"
assert self.id == "c1A2b3c"
return {}
```

View file

@ -379,52 +379,53 @@ class Header(Component):
}
```
### Static type hints
### Input validation and static type hints
Components API is fully typed, and supports [static type hints](https://django-components.github.io/django-components/latest/concepts/advanced/typing_and_validation/).
Avoid needless errors with [type hints and runtime input validation](https://django-components.github.io/django-components/latest/concepts/advanced/typing_and_validation/).
To opt-in to static type hints, define types for component's args, kwargs, slots, and more:
To opt-in to input validation, define types for component's args, kwargs, slots, and more:
```py
from typing import NotRequired, Tuple, TypedDict, SlotContent, Slot
from typing import NamedTuple, Optional
from django.template import Context
from django_components import Component, Slot, SlotInput
from django_components import Component
class Button(Component):
class Args(NamedTuple):
size: int
text: str
ButtonArgs = Tuple[int, str]
class Kwargs(NamedTuple):
variable: str
another: int
maybe_var: Optional[int] = None # May be omitted
class ButtonKwargs(TypedDict):
variable: str
another: int
maybe_var: NotRequired[int] # May be omitted
class Slots(NamedTuple):
my_slot: Optional[SlotInput] = None
another_slot: SlotInput
class ButtonSlots(TypedDict):
# Use `Slot` for slot functions.
my_slot: NotRequired[Slot]
# Use `SlotContent` when you want to allow either `Slot` instance or plain string
another_slot: SlotContent
ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
class Button(ButtonType):
def get_context_data(self, *args, **kwargs):
self.input.args[0] # int
self.input.kwargs["variable"] # str
self.input.slots["my_slot"] # Slot
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
args.size # int
kwargs.variable # str
slots.my_slot # Slot[MySlotData]
```
When you then call
To have type hints when calling
[`Button.render()`](https://django-components.github.io/django-components/latest/reference/api/#django_components.Component.render) or
[`Button.render_to_response()`](https://django-components.github.io/django-components/latest/reference/api/#django_components.Component.render_to_response),
you will get type hints:
wrap the inputs in their respective `Args`, `Kwargs`, and `Slots` classes:
```py
Button.render(
# Error: First arg must be `int`, got `float`
args=(1.25, "abc"),
args=Button.Args(
size=1.25,
text="abc",
),
# Error: Key "another" is missing
kwargs={
"variable": "text",
},
kwargs=Button.Kwargs(
variable="text",
),
)
```

View file

@ -71,11 +71,7 @@
options:
show_if_no_docstring: true
::: django_components.EmptyDict
options:
show_if_no_docstring: true
::: django_components.EmptyTuple
::: django_components.Empty
options:
show_if_no_docstring: true
@ -95,6 +91,10 @@
options:
show_if_no_docstring: true
::: django_components.SlotInput
options:
show_if_no_docstring: true
::: django_components.SlotRef
options:
show_if_no_docstring: true

View file

@ -54,7 +54,8 @@ python manage.py components ext run <extension> <command>
## `components create`
```txt
usage: python manage.py components create [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run]
usage: python manage.py components create [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose]
[--dry-run]
name
```
@ -237,7 +238,7 @@ List all extensions.
- `--columns COLUMNS`
- Comma-separated list of columns to show. Available columns: name. Defaults to `--columns name`.
- `-s`, `--simple`
- Only show table data, without headers. Use this option for generating machine-readable output.
- Only show table data, without headers. Use this option for generating machine- readable output.
@ -400,7 +401,7 @@ List all components created in this project.
- `--columns COLUMNS`
- Comma-separated list of columns to show. Available columns: name, full_name, path. Defaults to `--columns full_name,path`.
- `-s`, `--simple`
- Only show table data, without headers. Use this option for generating machine-readable output.
- Only show table data, without headers. Use this option for generating machine- readable output.
@ -463,7 +464,8 @@ ProjectDashboardAction project.components.dashboard_action.ProjectDashboardAc
```txt
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
[--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] [--skip-checks]
[--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color]
[--skip-checks]
```
@ -507,9 +509,10 @@ Deprecated. Use `components upgrade` instead.
## `startcomponent`
```txt
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose]
[--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
[--traceback] [--no-color] [--force-color] [--skip-checks]
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force]
[--verbose] [--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
[--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color]
[--skip-checks]
name
```

View file

@ -20,7 +20,7 @@ Import as
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1037" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1066" target="_blank">See source code</a>
@ -43,7 +43,7 @@ If you insert this tag multiple times, ALL CSS links will be duplicately inserte
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1059" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1088" target="_blank">See source code</a>
@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1655" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L2622" target="_blank">See source code</a>
@ -175,7 +175,7 @@ can access only the data that was explicitly passed to it:
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L614" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L648" target="_blank">See source code</a>
@ -416,7 +416,7 @@ user = self.inject("user_data")["user"]
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L155" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L189" target="_blank">See source code</a>

View file

@ -52,7 +52,7 @@ from django_components.extensions.view import ComponentView
from django_components.extensions.url import ComponentUrl, get_component_url
from django_components.library import TagProtectedError
from django_components.node import BaseNode, template_tag
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
from django_components.slots import Slot, SlotContent, SlotFunc, SlotInput, SlotRef, SlotResult
from django_components.tag_formatter import (
ComponentFormatter,
ShorthandComponentFormatter,
@ -65,7 +65,7 @@ from django_components.template import cached_template
import django_components.types as types
from django_components.util.loader import ComponentFileEntry, get_component_dirs, get_component_files
from django_components.util.routing import URLRoute, URLRouteHandler
from django_components.util.types import EmptyTuple, EmptyDict
from django_components.util.types import Empty
# isort: on
@ -103,8 +103,7 @@ __all__ = [
"ContextBehavior",
"Default",
"DynamicComponent",
"EmptyTuple",
"EmptyDict",
"Empty",
"format_attributes",
"get_component_by_class_id",
"get_component_dirs",
@ -126,9 +125,10 @@ __all__ = [
"RegistrySettings",
"render_dependencies",
"ShorthandComponentFormatter",
"SlotContent",
"Slot",
"SlotContent",
"SlotFunc",
"SlotInput",
"SlotRef",
"SlotResult",
"TagFormatterABC",

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import sys
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar, Union
from weakref import ReferenceType, finalize
from django.template import Library
@ -18,15 +18,7 @@ from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
from django_components.util.weakref import cached_ref
if TYPE_CHECKING:
from django_components.component import (
ArgsType,
Component,
CssDataType,
DataType,
JsDataType,
KwargsType,
SlotsType,
)
from django_components.component import Component
# NOTE: `ReferenceType` is NOT a generic pre-3.9
@ -36,6 +28,9 @@ else:
AllRegistries = List[ReferenceType]
TComponent = TypeVar("TComponent", bound="Component")
class AlreadyRegistered(Exception):
"""
Raised when you try to register a [Component](../api#django_components#Component),
@ -264,6 +259,10 @@ class ComponentRegistry:
)
def __del__(self) -> None:
# Skip if `extensions` was deleted before this registry
if not extensions:
return
extensions.on_registry_deleted(
OnRegistryDeletedContext(
registry=self,
@ -615,8 +614,8 @@ _the_registry = registry
def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callable[
[Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"]],
Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"],
[Type[TComponent]],
Type[TComponent],
]:
"""
Class decorator for registering a [component](./#django_components.Component)
@ -661,9 +660,7 @@ def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callabl
if registry is None:
registry = _the_registry
def decorator(
component: Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"],
) -> Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"]:
def decorator(component: Type[TComponent]) -> Type[TComponent]:
registry.register(name=name, component=component)
return component

View file

@ -10,6 +10,7 @@ from typing import (
Dict,
List,
Literal,
Mapping,
Optional,
Sequence,
Set,
@ -134,7 +135,7 @@ def cache_component_js(comp_cls: Type["Component"]) -> None:
# with `Components.manager.registerComponentData`.
# 3. Actually run a component's JS instance with `Components.manager.callComponent`,
# specifying the components HTML elements with `component_id`, and JS vars with `input_hash`.
def cache_component_js_vars(comp_cls: Type["Component"], js_vars: Dict) -> Optional[str]:
def cache_component_js_vars(comp_cls: Type["Component"], js_vars: Mapping) -> Optional[str]:
if not is_nonempty_str(comp_cls.js):
return None
@ -184,7 +185,7 @@ def cache_component_css(comp_cls: Type["Component"]) -> None:
# the CSS vars under the CSS selector `[data-djc-css-a1b2c3]`. We define the stylesheet
# with variables separately from `Component.css`, because different instances may return different
# data from `get_css_data()`, which will live in different stylesheets.
def cache_component_css_vars(comp_cls: Type["Component"], css_vars: Dict) -> Optional[str]:
def cache_component_css_vars(comp_cls: Type["Component"], css_vars: Mapping) -> Optional[str]:
if not is_nonempty_str(comp_cls.css):
return None

View file

@ -125,7 +125,10 @@ class ComponentDefaults(ComponentExtension.ExtensionClass): # type: ignore[misc
The fields of this class are used to set default values for the component's kwargs.
Read more about [Component defaults](../../concepts/fundamentals/component_defaults).
**Example:**
```python
from django_components import Component, Default
@ -133,6 +136,7 @@ class ComponentDefaults(ComponentExtension.ExtensionClass): # type: ignore[misc
class Defaults:
position = "left"
selected_items = Default(lambda: [1, 2, 3])
```
"""
pass

View file

@ -17,17 +17,25 @@ class ComponentView(ComponentExtension.ExtensionClass, View): # type: ignore
"""
The interface for `Component.View`.
Override the methods of this class to define the behavior of the component.
The fields of this class are used to configure the component views and URLs.
This class is a subclass of `django.views.View`. The `Component` instance is available
This class is a subclass of
[`django.views.View`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#view).
The [`Component`](../api#django_components.Component) instance is available
via `self.component`.
Override the methods of this class to define the behavior of the component.
Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
**Example:**
```python
class MyComponent(Component):
class View:
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
return HttpResponse("Hello, world!")
```
"""
# NOTE: This attribute must be declared on the class for `View.as_view()` to allow

View file

@ -248,7 +248,7 @@ def component_post_render(
# This is where we actually render the component
#
# NOTE: [1:] because the root component will be yet again added to the error's
# `components` list in `_render` so we remove the first element from the path.
# `components` list in `_render_with_error_trace` so we remove the first element from the path.
with component_error_message(full_path[1:]):
curr_comp_content, grandchild_component_attrs = curr_comp_renderer(curr_comp_attrs)

View file

@ -99,7 +99,41 @@ class Slot(Generic[TSlotData]):
# NOTE: This must be defined here, so we don't have any forward references
# otherwise Pydantic has problem resolving the types.
SlotContent = Union[SlotResult, SlotFunc[TSlotData], Slot[TSlotData]]
SlotInput = Union[SlotResult, SlotFunc[TSlotData], Slot[TSlotData]]
"""
When rendering a component with [`Component.render()`](../api#django_components.Component.render)
or [`Component.render_to_response()`](../api#django_components.Component.render_to_response),
the slots may be given a strings, functions, or [`Slot`](../api#django_components.Slot) instances.
This type describes that union.
Use this type when typing the slots in your component.
`SlotInput` accepts an optional type parameter to specify the data dictionary that will be passed to the
slot content function.
**Example:**
```python
from typing import NamedTuple
from typing_extensions import TypedDict
from django_components import Component, SlotInput
class TableFooterSlotData(TypedDict):
page_number: int
class Table(Component):
class Slots(NamedTuple):
header: SlotInput
footer: SlotInput[TableFooterSlotData]
template = "<div>{% slot 'footer' %}</div>"
```
"""
# TODO_V1 - REMOVE, superseded by SlotInput
SlotContent = SlotInput[TSlotData]
"""
DEPRECATED: Use [`SlotInput`](../api#django_components.SlotInput) instead. Will be removed in v1.
"""
# Internal type aliases

View file

@ -1,6 +1,6 @@
"""Helper types for IDEs."""
from django_components.util.types import Annotated
from typing_extensions import Annotated
css = Annotated[str, "css"]
django_html = Annotated[str, "django_html"]

View file

@ -1,5 +1,6 @@
import re
import sys
from dataclasses import asdict, is_dataclass
from hashlib import md5
from importlib import import_module
from itertools import chain
@ -130,3 +131,19 @@ def is_glob(filepath: str) -> bool:
def flatten(lst: Iterable[Iterable[T]]) -> List[T]:
return list(chain.from_iterable(lst))
def to_dict(data: Any) -> dict:
"""
Convert object to a dict.
Handles `dict`, `NamedTuple`, and `dataclass`.
"""
if isinstance(data, dict):
return data
elif hasattr(data, "_asdict"): # Case: NamedTuple
return data._asdict()
elif is_dataclass(data): # Case: dataclass
return asdict(data) # type: ignore[arg-type]
return dict(data)

View file

@ -1,144 +1,30 @@
import sys
import typing
from typing import Any, Tuple
# See https://peps.python.org/pep-0655/#usage-in-python-3-11
# NOTE: Pydantic requires typing_extensions.TypedDict until (not incl) 3.12
if sys.version_info >= (3, 12):
from typing import TypedDict
else:
from typing_extensions import TypedDict as TypedDict
try:
from typing import Annotated # type: ignore
except ImportError:
@typing.no_type_check
class Annotated: # type: ignore
def __init__(self, type_: str, *args: Any, **kwargs: Any):
self.type_ = type_
self.metadata = args, kwargs
def __repr__(self) -> str:
return f"Annotated[{self.type_}, {self.metadata[0]!r}, {self.metadata[1]!r}]"
def __getitem__(self, params: Any) -> "Annotated[Any, Any, Any]": # type: ignore
if not isinstance(params, tuple):
params = (params,)
return Annotated(self.type_, *params, **self.metadata[1]) # type: ignore
def __class_getitem__(self, *params: Any) -> "Annotated[Any, Any, Any]": # type: ignore
return Annotated(*params) # type: ignore
from typing import NamedTuple
EmptyTuple = Tuple[()]
"""
Tuple with no members.
You can use this to define a [Component](../api#django_components.Component)
that accepts NO positional arguments:
```python
from django_components import Component, EmptyTuple
class Table(Component(EmptyTuple, Any, Any, Any, Any, Any))
...
```
After that, when you call [`Component.render()`](../api#django_components.Component.render)
or [`Component.render_to_response()`](../api#django_components.Component.render_to_response),
the `args` parameter will raise type error if `args` is anything else than an empty
tuple.
```python
Table.render(
args: (),
)
```
Omitting `args` is also fine:
```python
Table.render()
```
Other values are not allowed. This will raise an error with MyPy:
```python
Table.render(
args: ("one", 2, "three"),
)
```
"""
class EmptyDict(TypedDict):
class Empty(NamedTuple):
"""
TypedDict with no members.
Type for an object with no members.
You can use this to define a [Component](../api#django_components.Component)
that accepts NO kwargs, or NO slots, or returns NO data from
[`Component.get_context_data()`](../api#django_components.Component.get_context_data)
/
[`Component.get_js_data()`](../api#django_components.Component.get_js_data)
/
[`Component.get_css_data()`](../api#django_components.Component.get_css_data):
Accepts NO kwargs:
You can use this to define [Component](../api#django_components.Component)
types that accept NO args, kwargs, slots, etc:
```python
from django_components import Component, EmptyDict
from django_components import Component, Empty
class Table(Component(Any, EmptyDict, Any, Any, Any, Any))
class Table(Component):
Args = Empty
Kwargs = Empty
...
```
Accepts NO slots:
This class is a shorthand for:
```python
from django_components import Component, EmptyDict
class Table(Component(Any, Any, EmptyDict, Any, Any, Any))
...
```py
class Empty(NamedTuple):
pass
```
Returns NO data from `get_context_data()`:
```python
from django_components import Component, EmptyDict
class Table(Component(Any, Any, Any, EmptyDict, Any, Any))
...
```
Going back to the example with NO kwargs, when you then call
[`Component.render()`](../api#django_components.Component.render)
or [`Component.render_to_response()`](../api#django_components.Component.render_to_response),
the `kwargs` parameter will raise type error if `kwargs` is anything else than an empty
dict.
```python
Table.render(
kwargs: {},
)
```
Omitting `kwargs` is also fine:
```python
Table.render()
```
Other values are not allowed. This will raise an error with MyPy:
```python
Table.render(
kwargs: {
"one": 2,
"three": 4,
},
)
```
Read more about [Typing and validation](../../concepts/advanced/typing_and_validation).
"""
pass

View file

@ -85,10 +85,10 @@ class TestComponentMediaCache:
<div>Template only component</div>
"""
def get_js_data(self):
def get_js_data(self, args, kwargs, slots, context):
return {}
def get_css_data(self):
def get_css_data(self, args, kwargs, slots, context):
return {}
@register("test_media_no_vars")
@ -100,10 +100,10 @@ class TestComponentMediaCache:
js = "console.log('Hello from JS');"
css = ".novars-component { color: blue; }"
def get_js_data(self):
def get_js_data(self, args, kwargs, slots, context):
return {}
def get_css_data(self):
def get_css_data(self, args, kwargs, slots, context):
return {}
class TestMediaAndVarsComponent(Component):
@ -114,10 +114,10 @@ class TestComponentMediaCache:
js = "console.log('Hello from full component');"
css = ".full-component { color: blue; }"
def get_js_data(self):
def get_js_data(self, args, kwargs, slots, context):
return {"message": "Hello"}
def get_css_data(self):
def get_css_data(self, args, kwargs, slots, context):
return {"color": "blue"}
# Register our test cache

View file

@ -4,8 +4,7 @@ For tests focusing on the `component` tag, see `test_templatetags_component.py`
"""
import re
from typing import Any, Dict, Tuple, no_type_check
from typing_extensions import NotRequired, TypedDict
from typing import Any, Dict, no_type_check
import pytest
from django.conf import settings
@ -20,8 +19,6 @@ from pytest_django.asserts import assertHTMLEqual, assertInHTML
from django_components import (
Component,
ComponentView,
SlotContent,
Slot,
all_components,
get_component_by_class_id,
register,
@ -385,100 +382,6 @@ class TestComponent:
assert SimpleComponent.render() == "Hello"
def test_typing(self):
# Types
ButtonArgs = Tuple[str, ...]
class ButtonKwargs(TypedDict):
name: str
age: int
maybe_var: NotRequired[int]
class ButtonFooterSlotData(TypedDict):
value: int
class ButtonSlots(TypedDict):
# Use `SlotContent` when you want to allow either function (`Slot` instance)
# or plain string.
header: SlotContent
# Use `Slot` for slot functions. The generic specifies the data available to the slot function.
footer: NotRequired[Slot[ButtonFooterSlotData]]
# Data returned from `get_context_data`
class ButtonData(TypedDict):
data1: str
data2: int
# Data returned from `get_js_data`
class ButtonJsData(TypedDict):
js_data1: str
js_data2: int
# Data returned from `get_css_data`
class ButtonCssData(TypedDict):
css_data1: str
css_data2: int
# Tests - We simply check that these don't raise any errors
# nor any type errors.
ButtonType1 = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, ButtonJsData, ButtonCssData]
ButtonType2 = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData, ButtonJsData]
ButtonType3 = Component[ButtonArgs, ButtonKwargs, ButtonSlots, ButtonData]
ButtonType4 = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
ButtonType5 = Component[ButtonArgs, ButtonKwargs]
ButtonType6 = Component[ButtonArgs]
class Button1(ButtonType1):
template = "<button>Click me!</button>"
class Button2(ButtonType2):
template = "<button>Click me!</button>"
class Button3(ButtonType3):
template = "<button>Click me!</button>"
class Button4(ButtonType4):
template = "<button>Click me!</button>"
class Button5(ButtonType5):
template = "<button>Click me!</button>"
class Button6(ButtonType6):
template = "<button>Click me!</button>"
Button1.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button2.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button3.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button4.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
slots={"header": "HEADER", "footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER")},
)
Button5.render(
args=("arg1", "arg2"),
kwargs={"name": "name", "age": 123},
)
Button6.render(
args=("arg1", "arg2"),
)
@djc_test
class TestComponentRender:

View file

@ -32,7 +32,7 @@ class TestComponentDefaults:
# Check that args and slots are NOT affected by the defaults
assert self.input.args == [123]
assert [*self.input.slots.keys()] == ["my_slot"]
assert self.input.slots["my_slot"](Context(), None, None) == "MY_SLOT"
assert self.input.slots["my_slot"](Context(), None, None) == "MY_SLOT" # type: ignore[arg-type]
assert self.input.kwargs == {
"variable": "test", # User-given

View file

@ -0,0 +1,615 @@
import re
from dataclasses import dataclass
from typing import NamedTuple, Optional
from typing_extensions import NotRequired, TypedDict
import pytest
from django.template import Context
from django_components import Component, Empty, Slot, SlotInput
from django_components.testing import djc_test
from .testutils import setup_test_config
setup_test_config({"autodiscover": False})
@djc_test
class TestComponentTyping:
def test_data_methods_input_typed(self):
template_called = False
js_called = False
css_called = False
class ButtonFooterSlotData(TypedDict):
value: int
class Button(Component):
class Args(NamedTuple):
arg1: str
arg2: str
@dataclass
class Kwargs:
name: str
age: int
maybe_var: Optional[int] = None
class Slots(TypedDict):
# Use `SlotInput` when you want to pass slot as string
header: SlotInput
# Use `Slot` for slot functions.
# The generic specifies the data available to the slot function
footer: NotRequired[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, dict)
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, dict)
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, dict)
assert isinstance(context, Context)
template = "<button>Click me!</button>"
Button.render(
args=Button.Args(arg1="arg1", arg2="arg2"),
kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots(
header="HEADER",
footer=Slot(lambda ctx, slot_data, slot_ref: "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
css_called = False
class Button(Component):
def get_template_data(self, args, kwargs, slots, context):
nonlocal template_called
template_called = True
assert isinstance(args, list)
assert isinstance(kwargs, dict)
assert isinstance(slots, dict)
assert isinstance(context, Context)
def get_js_data(self, args, kwargs, slots, context):
nonlocal js_called
js_called = True
assert isinstance(args, list)
assert isinstance(kwargs, dict)
assert isinstance(slots, dict)
assert isinstance(context, Context)
def get_css_data(self, args, kwargs, slots, context):
nonlocal css_called
css_called = True
assert isinstance(args, list)
assert isinstance(kwargs, dict)
assert isinstance(slots, dict)
assert isinstance(context, Context)
template = "<button>Click me!</button>"
Button.render(
args=["arg1", "arg2"],
kwargs={"name": "name", "age": 123},
slots={
"header": "HEADER",
"footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER"),
},
)
assert template_called
assert js_called
assert css_called
def test_data_methods_output_typed(self):
template_called = False
js_called = False
css_called = False
template_data_instance = None
js_data_instance = None
css_data_instance = None
class Button(Component):
# Data returned from `get_context_data`
@dataclass
class TemplateData:
data1: str
data2: int
def __post_init__(self):
nonlocal template_data_instance
template_data_instance = self
# Data returned from `get_js_data`
@dataclass
class JsData:
js_data1: str
js_data2: int
def __post_init__(self):
nonlocal js_data_instance
js_data_instance = self
# Data returned from `get_css_data`
@dataclass
class CssData:
css_data1: str
css_data2: int
def __post_init__(self):
nonlocal css_data_instance
css_data_instance = self
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>"
Button.render(
args=["arg1", "arg2"],
kwargs={"name": "name", "age": 123},
slots={
"header": "HEADER",
"footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER"),
},
)
assert template_called
assert js_called
assert css_called
assert isinstance(template_data_instance, Button.TemplateData)
assert isinstance(js_data_instance, Button.JsData)
assert isinstance(css_data_instance, Button.CssData)
assert template_data_instance == Button.TemplateData(data1="data1", data2=123)
assert js_data_instance == Button.JsData(js_data1="js_data1", js_data2=123)
assert css_data_instance == Button.CssData(css_data1="css_data1", css_data2=123)
def test_data_methods_output_typed_reuses_instances(self):
template_called = False
js_called = False
css_called = False
template_data_instance = None
js_data_instance = None
css_data_instance = None
class Button(Component):
# Data returned from `get_context_data`
@dataclass
class TemplateData:
data1: str
data2: int
def __post_init__(self):
nonlocal template_data_instance
template_data_instance = self
# Data returned from `get_js_data`
@dataclass
class JsData:
js_data1: str
js_data2: int
def __post_init__(self):
nonlocal js_data_instance
js_data_instance = self
# Data returned from `get_css_data`
@dataclass
class CssData:
css_data1: str
css_data2: int
def __post_init__(self):
nonlocal css_data_instance
css_data_instance = self
def get_template_data(self, args, kwargs, slots, context):
nonlocal template_called
template_called = True
data = Button.TemplateData(
data1="data1",
data2=123,
)
# Reset the instance to None to check if the instance is reused
nonlocal template_data_instance
template_data_instance = None
return data
def get_js_data(self, args, kwargs, slots, context):
nonlocal js_called
js_called = True
data = Button.JsData(
js_data1="js_data1",
js_data2=123,
)
# Reset the instance to None to check if the instance is reused
nonlocal js_data_instance
js_data_instance = None
return data
def get_css_data(self, args, kwargs, slots, context):
nonlocal css_called
css_called = True
data = Button.CssData(
css_data1="css_data1",
css_data2=123,
)
# Reset the instance to None to check if the instance is reused
nonlocal css_data_instance
css_data_instance = None
return data
template = "<button>Click me!</button>"
Button.render(
args=["arg1", "arg2"],
kwargs={"name": "name", "age": 123},
slots={
"header": "HEADER",
"footer": Slot(lambda ctx, slot_data, slot_ref: "FOOTER"),
},
)
assert template_called
assert js_called
assert css_called
assert template_data_instance is not None
assert js_data_instance is not None
assert css_data_instance is not None
def test_builtin_classes(self):
class ButtonFooterSlotData(TypedDict):
value: int
class Button(Component):
class Args(NamedTuple):
arg1: str
arg2: str
@dataclass
class Kwargs:
name: str
age: int
maybe_var: Optional[int] = None
class Slots(TypedDict):
# Use `SlotInput` when you want to pass slot as string
header: SlotInput
# Use `Slot` for slot functions.
# The generic specifies the data available to the slot function
footer: NotRequired[Slot[ButtonFooterSlotData]]
# Data returned from `get_context_data`
class TemplateData(NamedTuple):
data1: str
data2: int
data3: str
# Data returned from `get_js_data`
@dataclass
class JsData:
js_data1: str
js_data2: int
js_data3: str
# Data returned from `get_css_data`
@dataclass
class CssData:
css_data1: str
css_data2: int
css_data3: str
def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
return self.TemplateData(
data1=kwargs.name,
data2=kwargs.age,
data3=args.arg1,
)
def get_js_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
return self.JsData(
js_data1=kwargs.name,
js_data2=kwargs.age,
js_data3=args.arg1,
)
def get_css_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
return self.CssData(
css_data1=kwargs.name,
css_data2=kwargs.age,
css_data3=args.arg1,
)
template = "<button>Click me!</button>"
# Success case
Button.render(
args=Button.Args(arg1="arg1", arg2="arg2"),
kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots(
header="HEADER",
footer=Slot(lambda ctx, slot_data, slot_ref: "FOOTER"),
),
)
# Failure case 1: NamedTuple raises error when a required argument is missing
with pytest.raises(
TypeError,
match=re.escape("missing 1 required positional argument: 'arg2'"),
):
Button.render(
# Missing arg2
args=Button.Args(arg1="arg1"), # type: ignore[call-arg]
kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots(
header="HEADER",
footer=Slot(lambda ctx, slot_data, slot_ref: "FOOTER"),
),
)
# Failure case 2: Dataclass raises error when a required argument is missing
with pytest.raises(
TypeError,
match=re.escape("missing 1 required positional argument: 'name'"),
):
Button.render(
args=Button.Args(arg1="arg1", arg2="arg2"),
# Name missing
kwargs=Button.Kwargs(age=123), # type: ignore[call-arg]
slots=Button.Slots(
header="HEADER",
footer=Slot(lambda ctx, slot_data, slot_ref: "FOOTER"),
),
)
# Failure case 3
# NOTE: While we would expect this to raise, seems that TypedDict (`Slots`)
# does NOT raise an error when a required key is missing.
Button.render(
args=Button.Args(arg1="arg1", arg2="arg2"),
kwargs=Button.Kwargs(name="name", age=123),
slots=Button.Slots( # type: ignore[typeddict-item]
footer=Slot(lambda ctx, slot_data, slot_ref: "FOOTER"), # Missing header
),
)
# Failure case 4: Data object is not of the expected type
class ButtonBad(Button):
class TemplateData(NamedTuple):
data1: str
data2: int # Removed data3
with pytest.raises(
TypeError,
match=re.escape("got an unexpected keyword argument 'data3'"),
):
ButtonBad.render(
args=ButtonBad.Args(arg1="arg1", arg2="arg2"),
kwargs=ButtonBad.Kwargs(name="name", age=123),
)
def test_empty_type(self):
template_called = False
class Button(Component):
template = "Hello"
Args = Empty
Kwargs = Empty
def get_template_data(self, args, kwargs, slots, context):
nonlocal template_called
template_called = True
assert isinstance(args, Empty)
assert isinstance(kwargs, Empty)
# Success case
Button.render()
assert template_called
# Failure cases
with pytest.raises(TypeError, match=re.escape("got an unexpected keyword argument 'arg1'")):
Button.render(
args=Button.Args(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
)
with pytest.raises(TypeError, match=re.escape("got an unexpected keyword argument 'arg1'")):
Button.render(
kwargs=Button.Kwargs(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
)
def test_custom_args_class_raises_on_invalid(self):
class Button(Component):
template = "Hello"
class Args:
arg1: str
arg2: str
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 Button2(Component):
template = "Hello"
class Args:
arg1: str
arg2: str
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
with pytest.raises(TypeError, match=re.escape("'Args' object is not iterable")):
Button2.render(
args=Button2.Args(arg1="arg1", arg2="arg2"),
)
class Button3(Component):
template = "Hello"
class Args:
arg1: str
arg2: str
def __iter__(self):
return iter([self.arg1, self.arg2])
with pytest.raises(TypeError, match=re.escape("Args() takes no arguments")):
Button3.render(
args=Button3.Args(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
)
def test_custom_args_class_custom(self):
class Button(Component):
template = "Hello"
class Args:
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 Button(Component):
template = "Hello"
class Kwargs:
arg1: str
arg2: str
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 Button2(Component):
template = "Hello"
class Kwargs:
arg1: str
arg2: str
def __init__(self, arg1: str, arg2: str):
self.arg1 = arg1
self.arg2 = arg2
with pytest.raises(TypeError, match=re.escape("'Kwargs' object is not iterable")):
Button2.render(
kwargs=Button2.Kwargs(arg1="arg1", arg2="arg2"),
)
class Button3(Component):
template = "Hello"
class Kwargs:
arg1: str
arg2: str
def _asdict(self):
return {"arg1": self.arg1, "arg2": self.arg2}
with pytest.raises(TypeError, match=re.escape("Kwargs() takes no arguments")):
Button3.render(
kwargs=Button3.Kwargs(arg1="arg1", arg2="arg2"), # type: ignore[call-arg]
)
def test_custom_kwargs_class_custom(self):
class Button(Component):
template = "Hello"
class Kwargs:
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}
Button.render(
kwargs=Button.Kwargs(arg1="arg1", arg2="arg2"),
)

View file

@ -198,7 +198,10 @@ class TestComponentAsView(SimpleTestCase):
"""
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({"name": "Bob"}, {"second_slot": "Nice to meet you, Bob"})
return self.render_to_response(
context={"name": "Bob"},
slots={"second_slot": "Nice to meet you, Bob"},
)
client = CustomClient(urlpatterns=[path("test_slot/", MockComponentSlot.as_view())])
response = client.get("/test_slot/")
@ -223,7 +226,10 @@ class TestComponentAsView(SimpleTestCase):
"""
def get(self, request, *args, **kwargs) -> HttpResponse:
return self.render_to_response({}, {"test_slot": "<script>alert(1);</script>"})
return self.render_to_response(
context={},
slots={"test_slot": "<script>alert(1);</script>"},
)
client = CustomClient(urlpatterns=[path("test_slot_insecure/", MockInsecureComponentSlot.as_view())])
response = client.get("/test_slot_insecure/")