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

This commit is contained in:
Juro Oravec 2025-10-20 21:20:41 +00:00
parent c66bd21231
commit 0255782ee2
31 changed files with 620 additions and 294 deletions

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

@ -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,50 @@ 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) # noqa: PYI024
tuple_cls.__annotations__ = cls.__annotations__
return tuple_cls