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

@ -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/")