From 7d259ac291c3864e8afbcde04814a378386d4972 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Tue, 21 Oct 2025 16:05:54 +0000 Subject: [PATCH] feat: allow to set comp defaults on Kwargs class --- CHANGELOG.md | 62 ++++- .../fundamentals/component_defaults.md | 76 +++--- .../fundamentals/html_js_css_variables.md | 34 +-- .../parametrising_components.md | 53 +++- docs/reference/api.md | 4 + src/django_components/__init__.py | 3 +- src/django_components/commands/create.py | 5 +- src/django_components/component.py | 18 +- src/django_components/extensions/defaults.py | 162 ++++++++--- tests/test_component_defaults.py | 257 +++++++++++++++++- tests/test_component_dynamic.py | 5 +- 11 files changed, 560 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 345ffeee..8ab2ef9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,64 @@ # Release notes -## v0.142.4 +## v0.143.0 -#### Refactor +#### Feat -- Simpler syntax for defining component inputs. +- You can now define component input defaults directly on `Component.Kwargs`. + + Before, the defaults had to be defined on a separate `Component.Defaults` class: + + ```python + class ProfileCard(Component): + class Kwargs: + user_id: int + show_details: bool + + class Defaults: + show_details = True + ``` + + Now, django-components can detect the defaults from `Component.Kwargs` and apply + them. So you can merge `Component.Kwargs` with `Component.Defaults`: + + ```python + class ProfileCard(Component): + class Kwargs: + user_id: int + show_details: bool = True + ``` + + NOTE: This applies only when `Component.Kwargs` is a NamedTuple or dataclass. + +- New helper `get_component_defaults()`: + + Now, the defaults may be defined on either `Component.Defaults` and `Component.Kwargs` classes. + + To get a final, merged dictionary of all the component's defaults, use `get_component_defaults()`: + + ```py + from django_components import Component, Default, get_component_defaults + + class MyTable(Component): + class Kwargs: + position: str + order: int + items: list[int] + variable: str = "from_kwargs" + + class Defaults: + position: str = "left" + items = Default(lambda: [1, 2, 3]) + + defaults = get_component_defaults(MyTable) + # { + # "position": "left", + # "items": [1, 2, 3], + # "variable": "from_kwargs", + # } + ``` + +- Simpler syntax for defining component inputs: When defining `Args`, `Kwargs`, `Slots`, `JsData`, `CssData`, `TemplateData`, these data classes now don't have to subclass any other class. @@ -51,6 +105,8 @@ ... ``` +#### Refactor + - Extension authors: The `ExtensionComponentConfig` can be instantiated with `None` instead of a component instance. This allows to call component-level extension methods outside of the normal rendering lifecycle. diff --git a/docs/concepts/fundamentals/component_defaults.md b/docs/concepts/fundamentals/component_defaults.md index 2ca02af6..57f549b5 100644 --- a/docs/concepts/fundamentals/component_defaults.md +++ b/docs/concepts/fundamentals/component_defaults.md @@ -65,25 +65,33 @@ and so `selected_items` will be set to `[1, 2, 3]`. The defaults are aplied only to keyword arguments. They are NOT applied to positional arguments! +### Defaults from `Kwargs` + +If you are using [`Component.Kwargs`](../fundamentals/typing_and_validation.md#typing-inputs) to specify the component input, +you can set the defaults directly on `Kwargs`: + +```python +class ProfileCard(Component): + class Kwargs: + user_id: int + show_details: bool = True +``` + +Which is the same as: + +```python +class ProfileCard(Component): + class Kwargs: + user_id: int + show_details: bool + + class Defaults: + show_details = True +``` + !!! warning - When [typing](../fundamentals/typing_and_validation.md) your components with [`Args`](../../../reference/api/#django_components.Component.Args), - [`Kwargs`](../../../reference/api/#django_components.Component.Kwargs), - or [`Slots`](../../../reference/api/#django_components.Component.Slots) classes, - you may be inclined to define the defaults in the classes. - - ```py - class ProfileCard(Component): - class Kwargs: - show_details: bool = True - ``` - - This is **NOT recommended**, because: - - - The defaults will NOT be applied to inputs when using [`self.raw_kwargs`](../../../reference/api/#django_components.Component.raw_kwargs) property. - - The defaults will NOT be applied when a field is given but set to `None`. - - Instead, define the defaults in the [`Defaults`](../../../reference/api/#django_components.Component.Defaults) class. + This works only when `Component.Kwargs` is a plain class, NamedTuple or dataclass. ### Default factories @@ -124,30 +132,28 @@ class MyTable(Component): ### Accessing defaults -Since the defaults are defined on the component class, you can access the defaults for a component with the [`Component.Defaults`](../../../reference/api#django_components.Component.Defaults) property. +The defaults may be defined on both [`Component.Defaults`](../../../reference/api#django_components.Component.Defaults) and [`Component.Kwargs`](../../../reference/api#django_components.Component.Kwargs) classes. -So if we have a component like this: +To get a final, merged dictionary of all the component's defaults, use [`get_component_defaults()`](../../../reference/api#django_components.get_component_defaults): ```py -from django_components import Component, Default, register +from django_components import Component, Default, get_component_defaults -@register("my_table") class MyTable(Component): + class Kwargs: + position: str + order: int + items: list[int] + variable: str = "from_kwargs" class Defaults: - position = "left" - selected_items = Default(lambda: [1, 2, 3]) + position: str = "left" + items = Default(lambda: [1, 2, 3]) - def get_template_data(self, args, kwargs, slots, context): - return { - "position": kwargs["position"], - "selected_items": kwargs["selected_items"], - } -``` - -We can access individual defaults like this: - -```py -print(MyTable.Defaults.position) -print(MyTable.Defaults.selected_items) +defaults = get_component_defaults(MyTable) +# { +# "position": "left", +# "items": [1, 2, 3], +# "variable": "from_kwargs", +# } ``` diff --git a/docs/concepts/fundamentals/html_js_css_variables.md b/docs/concepts/fundamentals/html_js_css_variables.md index dd3b4d05..34fb9315 100644 --- a/docs/concepts/fundamentals/html_js_css_variables.md +++ b/docs/concepts/fundamentals/html_js_css_variables.md @@ -17,10 +17,7 @@ Each method handles the data independently - you can define different data for t class ProfileCard(Component): class Kwargs: user_id: int - show_details: bool - - class Defaults: - show_details = True + show_details: bool = True def get_template_data(self, args, kwargs: Kwargs, slots, context): user = User.objects.get(id=kwargs.user_id) @@ -304,7 +301,7 @@ class ProfileCard(Component): ## Default values -You can use [`Defaults`](../../../reference/api/#django_components.Component.Defaults) class to provide default values for your inputs. +You can use the [`Defaults`](../../../reference/api/#django_components.Component.Defaults) and [`Kwargs`](../../../reference/api/#django_components.Component.Kwargs) classes to provide default values for your inputs. These defaults will be applied either when: @@ -321,12 +318,9 @@ from django_components import Component, Default, register @register("profile_card") class ProfileCard(Component): class Kwargs: - show_details: bool + # Will be set to True if `None` or missing + show_details: bool = True - class Defaults: - show_details = True - - # show_details will be set to True if `None` or missing def get_template_data(self, args, kwargs: Kwargs, slots, context): return { "show_details": kwargs.show_details, @@ -335,26 +329,6 @@ class ProfileCard(Component): ... ``` -!!! warning - - When typing your components with [`Args`](../../../reference/api/#django_components.Component.Args), - [`Kwargs`](../../../reference/api/#django_components.Component.Kwargs), - or [`Slots`](../../../reference/api/#django_components.Component.Slots) classes, - you may be inclined to define the defaults in the classes. - - ```py - class ProfileCard(Component): - class Kwargs: - show_details: bool = True - ``` - - This is **NOT recommended**, because: - - - The defaults will NOT be applied to inputs when using [`self.raw_kwargs`](../../../reference/api/#django_components.Component.raw_kwargs) property. - - The defaults will NOT be applied when a field is given but set to `None`. - - Instead, define the defaults in the [`Defaults`](../../../reference/api/#django_components.Component.Defaults) class. - ## Accessing Render API All three data methods have access to the Component's [Render API](../render_api), which includes: diff --git a/docs/getting_started/parametrising_components.md b/docs/getting_started/parametrising_components.md index 7ebc3c0d..b0ba21a4 100644 --- a/docs/getting_started/parametrising_components.md +++ b/docs/getting_started/parametrising_components.md @@ -233,7 +233,7 @@ or [`get_css_data()`](../../reference/api#django_components.Component.get_css_da To make things easier, Components can specify their defaults. Defaults are used when no value is provided, or when the value is set to `None` for a particular input. -To define defaults for a component, you create a nested `Defaults` class within your +To define defaults for a component, you create a nested [`Defaults`](../../reference/api#django_components.Component.Defaults) class within your [`Component`](../../reference/api#django_components.Component) class. Each attribute in the `Defaults` class represents a default value for a corresponding input. @@ -255,6 +255,57 @@ class Calendar(Component): } ``` +### 6. Add input validation + +Right now our `Calendar` component accepts any number of args and kwargs, +and we can't see which ones are being used. + +*This is a maintenance nightmare!* + +Let's be good colleagues and document the component inputs. +As a bonus, we will also get runtime validation of these inputs. + +For defining component inputs, there's 3 options: + +- [`Args`](../../reference/api#django_components.Component.Args) - For defining positional args passed to the component +- [`Kwargs`](../../reference/api#django_components.Component.Kwargs) - For keyword args +- [`Slots`](../../reference/api#django_components.Component.Slots) - For slots + +Our calendar component is using only kwargs, so we can ignore `Args` and `Slots`. +The new `Kwargs` class defines fields that this component accepts: + +```py +from django_components import Component, Default, register + +@register("calendar") +class Calendar(Component): + template_file = "calendar.html" + + class Kwargs: # <--- changed (replaced Defaults) + date: Date + extra_class: str = "text-blue" + + def get_template_data(self, args, kwargs: Kwargs, slots, context): # <--- changed + workweek_date = to_workweek_date(kwargs.date) # <--- changed + return { + "date": workweek_date, + "extra_class": kwargs.extra_class, # <--- changed + } +``` + +Notice that: + +- When we defined `Kwargs` class, the `kwargs` parameter to `get_template_data` + changed to an instance of `Kwargs`. Fields are now accessed as attributes. +- Since `kwargs` is of class `Kwargs`, we've added annotation to the `kwargs` parameter. +- `Kwargs` replaced `Defaults`, because defaults can be defined also on `Kwargs` class. + +And that's it! Now you can sleep safe knowing you won't break anything when +adding or removing component inputs. + +Read more about [Component defaults](../concepts/fundamentals/component_defaults.md) +and [Typing and validation](../concepts/fundamentals/typing_and_validation.md). + --- Next, you will learn [how to use slots give your components even more flexibility ➡️](./adding_slots.md) diff --git a/docs/reference/api.md b/docs/reference/api.md index c0163b2d..fb457edd 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -171,6 +171,10 @@ options: show_if_no_docstring: true +::: django_components.get_component_defaults + options: + show_if_no_docstring: true + ::: django_components.get_component_dirs options: show_if_no_docstring: true diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index 62730d94..5d4637fa 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -52,7 +52,7 @@ from django_components.extension import ( OnTemplateLoadedContext, ) from django_components.extensions.cache import ComponentCache -from django_components.extensions.defaults import ComponentDefaults, Default +from django_components.extensions.defaults import ComponentDefaults, Default, get_component_defaults from django_components.extensions.debug_highlight import ComponentDebugHighlight from django_components.extensions.view import ComponentView, get_component_url from django_components.library import TagProtectedError @@ -162,6 +162,7 @@ __all__ = [ "component_shorthand_formatter", "format_attributes", "get_component_by_class_id", + "get_component_defaults", "get_component_dirs", "get_component_files", "get_component_url", diff --git a/src/django_components/commands/create.py b/src/django_components/commands/create.py index 1e5bc540..0ce4c2a7 100644 --- a/src/django_components/commands/create.py +++ b/src/django_components/commands/create.py @@ -208,10 +208,7 @@ class CreateCommand(ComponentCommand): css_file = "{css_filename}" class Kwargs: - param: str - - class Defaults: - param = "sample value" + param: str = "sample value" def get_template_data(self, args, kwargs: Kwargs, slots, context): return {{ diff --git a/src/django_components/component.py b/src/django_components/component.py index 9aa118ed..24cabdcd 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -527,6 +527,9 @@ class ComponentMeta(ComponentMediaMeta): # class Kwargs: # ... # ``` + # NOTE: Using dataclasses with `slots=True` could be faster than using NamedTuple, + # but in real world web pages that may load 1-2s, data access and instantiation + # is only on the order of milliseconds, or about 0.1% of the overall time. for data_class_name in ["Args", "Kwargs", "Slots", "TemplateData", "JsData", "CssData"]: data_class = attrs.get(data_class_name) # Not a class @@ -698,7 +701,7 @@ class Component(metaclass=ComponentMeta): class Table(Component): class Kwargs: color: str - size: int + size: int = 10 def get_template_data(self, args, kwargs: Kwargs, slots, context): assert isinstance(kwargs, Table.Kwargs) @@ -714,6 +717,7 @@ class Component(metaclass=ComponentMeta): - Validate the input at runtime. - Set type hints for the keyword arguments for data methods like [`get_template_data()`](../api#django_components.Component.get_template_data). + - Set defaults for individual fields - Document the component inputs. You can also use `Kwargs` to validate the keyword arguments for @@ -725,6 +729,10 @@ class Component(metaclass=ComponentMeta): ) ``` + The defaults set on `Kwargs` will be merged with defaults from + [`Component.Defaults`](../api/#django_components.Component.Defaults) class. + `Kwargs` takes precendence. Read more about [Component defaults](../../concepts/fundamentals/component_defaults). + If you do not specify any bases, the `Kwargs` class will be automatically converted to a `NamedTuple`: @@ -2265,6 +2273,8 @@ class Component(metaclass=ComponentMeta): """ The fields of this class are used to set default values for the component's kwargs. + These defaults will be merged with defaults on [`Component.Kwargs`](../api/#django_components.Component.Kwargs). + Read more about [Component defaults](../../concepts/fundamentals/component_defaults). **Example:** @@ -2669,6 +2679,9 @@ class Component(metaclass=ComponentMeta): then the `kwargs` property will return an instance of that `Kwargs` class. - Otherwise, `kwargs` will be a plain dict. + Kwargs have the defaults applied to them. + Read more about [Component defaults](../../concepts/fundamentals/component_defaults). + **Example:** With `Kwargs` class: @@ -2715,6 +2728,9 @@ class Component(metaclass=ComponentMeta): is not typed and will remain as plain dict even if you define the [`Component.Kwargs`](../api/#django_components.Component.Kwargs) class. + `raw_kwargs` have the defaults applied to them. + Read more about [Component defaults](../../concepts/fundamentals/component_defaults). + **Example:** ```python diff --git a/src/django_components/extensions/defaults.py b/src/django_components/extensions/defaults.py index 54e2f263..f3c21b48 100644 --- a/src/django_components/extensions/defaults.py +++ b/src/django_components/extensions/defaults.py @@ -1,6 +1,8 @@ +import dataclasses import sys from dataclasses import MISSING, Field, dataclass -from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Type +from inspect import isclass +from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Type, Union from weakref import WeakKeyDictionary from django_components.extension import ( @@ -57,55 +59,137 @@ class ComponentDefaultField(NamedTuple): is_factory: bool +def get_component_defaults(component: Union[Type["Component"], "Component"]) -> Dict[str, Any]: + """ + Generate a defaults dictionary for a [`Component`](../api#django_components.Component). + + The defaults dictionary is generated from the [`Component.Defaults`](../api#django_components.Component.Defaults) + and [`Component.Kwargs`](../api#django_components.Component.Kwargs) classes. + `Kwargs` take precedence over `Defaults`. + + Read more about [Component defaults](../../concepts/fundamentals/component_defaults). + + **Example:** + + ```py + from django_components import Component, Default, get_component_defaults + + class MyTable(Component): + class Kwargs: + position: str + order: int + items: list[int] + variable: str = "from_kwargs" + + class Defaults: + position: str = "left" + items = Default(lambda: [1, 2, 3]) + + # Get the defaults dictionary + defaults = get_component_defaults(MyTable) + # { + # "position": "left", + # "items": [1, 2, 3], + # "variable": "from_kwargs", + # } + ``` + """ + component_cls = component if isclass(component) else component.__class__ + defaults_fields = defaults_by_component[component_cls] # type: ignore[index] + defaults: dict[str, Any] = {} + _apply_defaults(defaults, defaults_fields) + return defaults + + # Figure out which defaults are factories and which are not, at class creation, # so that the actual creation of the defaults dictionary is simple. -def _extract_defaults(defaults: Optional[Type]) -> List[ComponentDefaultField]: - defaults_fields: List[ComponentDefaultField] = [] - if defaults is None: - return defaults_fields +def _extract_defaults(defaults: Optional[Type], kwargs: Optional[Type]) -> List[ComponentDefaultField]: + """ + Given the `Defaults` and `Kwargs` classes from a component, this function extracts + the default values from them. + """ + # First, extract defaults from the `Defaults` class + defaults_fields_map: Dict[str, ComponentDefaultField] = {} + if defaults is not None: + for default_field_key in dir(defaults): + # Iterate only over fields set by the user (so non-dunder fields). + # Plus ignore `component_class` because that was set by the extension system. + # TODO_V1 - Remove `component_class` + if default_field_key.startswith("__") or default_field_key in {"component_class", "component_cls"}: + continue - for default_field_key in dir(defaults): - # Iterate only over fields set by the user (so non-dunder fields). - # Plus ignore `component_class` because that was set by the extension system. - # TODO_V1 - Remove `component_class` - if default_field_key.startswith("__") or default_field_key in {"component_class", "component_cls"}: - continue + default_field = getattr(defaults, default_field_key) - default_field = getattr(defaults, default_field_key) + if isinstance(default_field, property): + continue - if isinstance(default_field, property): - continue + # If the field was defined with dataclass.field(), take the default / factory from there. + if isinstance(default_field, Field): + if default_field.default is not MISSING: + field_value = default_field.default + is_factory = False + elif default_field.default_factory is not MISSING: + field_value = default_field.default_factory + is_factory = True + else: + field_value = None + is_factory = False - # If the field was defined with dataclass.field(), take the default / factory from there. - if isinstance(default_field, Field): - if default_field.default is not MISSING: - field_value = default_field.default - is_factory = False - elif default_field.default_factory is not MISSING: - field_value = default_field.default_factory + # If the field was defined with our `Default` class, it defined a factory + elif isinstance(default_field, Default): + field_value = default_field.value is_factory = True + + # If the field was defined with a simple assignment, assume it's NOT a factory. else: - field_value = None + field_value = default_field is_factory = False - # If the field was defined with our `Default` class, it defined a factory - elif isinstance(default_field, Default): - field_value = default_field.value - is_factory = True + field_data = ComponentDefaultField( + key=default_field_key, + value=field_value, + is_factory=is_factory, + ) + defaults_fields_map[default_field_key] = field_data - # If the field was defined with a simple assignment, assume it's NOT a factory. - else: - field_value = default_field - is_factory = False + # Next, extract defaults from the `Kwargs` class. + # We check for dataclasses and NamedTuple, as those are the supported ways to define defaults. + # Support for other types of `Kwargs` classes, like Pydantic models, is left to extensions. + kwargs_fields_map: Dict[str, ComponentDefaultField] = {} + if kwargs is not None: + if dataclasses.is_dataclass(kwargs): + for field in dataclasses.fields(kwargs): + if field.default is not dataclasses.MISSING: + field_value = field.default + is_factory = False + elif field.default_factory is not dataclasses.MISSING: + field_value = field.default_factory + is_factory = True + else: + continue # No default value - field_data = ComponentDefaultField( - key=default_field_key, - value=field_value, - is_factory=is_factory, - ) - defaults_fields.append(field_data) + field_data = ComponentDefaultField( + key=field.name, + value=field_value, + is_factory=is_factory, + ) + kwargs_fields_map[field.name] = field_data - return defaults_fields + # Check for NamedTuple. + # Note that we check for `_fields` to avoid accidentally matching `tuple` subclasses. + elif issubclass(kwargs, tuple) and hasattr(kwargs, "_fields"): + # `_field_defaults` is a dict of {field_name: default_value} + for field_name, default_value in getattr(kwargs, "_field_defaults", {}).items(): + field_data = ComponentDefaultField( + key=field_name, + value=default_value, + is_factory=False, + ) + kwargs_fields_map[field_name] = field_data + + # Merge the two, with `kwargs` overwriting `defaults`. + merged_fields_map = {**defaults_fields_map, **kwargs_fields_map} + return list(merged_fields_map.values()) def _apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None: @@ -177,7 +261,9 @@ class DefaultsExtension(ComponentExtension): # each time a component is rendered. def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None: defaults_cls = getattr(ctx.component_cls, "Defaults", None) - defaults_by_component[ctx.component_cls] = _extract_defaults(defaults_cls) + # Allow to simply define `Component.Kwargs` with defaults instead of 2 separate classes + kwargs_cls = getattr(ctx.component_cls, "Kwargs", None) + defaults_by_component[ctx.component_cls] = _extract_defaults(defaults_cls, kwargs_cls) # Apply defaults to missing or `None` values in `kwargs` def on_component_input(self, ctx: OnComponentInputContext) -> None: diff --git a/tests/test_component_defaults.py b/tests/test_component_defaults.py index f28889a1..8eea4349 100644 --- a/tests/test_component_defaults.py +++ b/tests/test_component_defaults.py @@ -1,8 +1,9 @@ -from dataclasses import field +from dataclasses import dataclass, field +from typing import NamedTuple from django.template import Context -from django_components import Component, Default +from django_components import Component, Default, get_component_defaults from django_components.testing import djc_test from .testutils import setup_test_config @@ -169,3 +170,255 @@ class TestComponentDefaults: ) assert did_call_context + + def test_defaults_from_kwargs_namedtuple(self): + did_call_context = False + + class TestComponent(Component): + template = "" + + class Kwargs(NamedTuple): + another: int + variable: str = "default_from_kwargs" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal did_call_context + did_call_context = True + + assert self.raw_kwargs == { + "variable": "default_from_kwargs", + "another": 123, + } + return {} + + TestComponent.render( + kwargs={"another": 123}, + ) + + assert did_call_context + + def test_defaults_from_kwargs_dataclass(self): + did_call_context = False + + class TestComponent(Component): + template = "" + + @dataclass + class Kwargs: + another: int + variable: str = "default_from_kwargs" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal did_call_context + did_call_context = True + + assert self.raw_kwargs == { + "variable": "default_from_kwargs", + "another": 123, + } + return {} + + TestComponent.render( + kwargs={"another": 123}, + ) + + assert did_call_context + + def test_defaults_from_kwargs_other_class(self): + did_call_context = False + + class CustomKwargs: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + self._kwargs = kwargs + + def _asdict(self): + return self._kwargs + + class TestComponent(Component): + template = "" + + class Kwargs(CustomKwargs): + another: int + variable: str = "default_from_kwargs" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal did_call_context + did_call_context = True + + # No defaults should be applied from a plain class + assert self.raw_kwargs == { + "another": 123, + } + return {} + + TestComponent.render( + kwargs={"another": 123}, + ) + + assert did_call_context + + def test_defaults_from_defaults_and_kwargs_namedtuple(self): + did_call_context = False + + class TestComponent(Component): + template = "" + + class Kwargs(NamedTuple): + from_defaults_only: str + variable: str = "from_kwargs" + from_kwargs_only: str = "kwargs_default" + + class Defaults: + variable = "from_defaults" + from_defaults_only = "defaults_default" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal did_call_context + did_call_context = True + + assert self.raw_kwargs == { + "variable": "from_kwargs", # Overridden by Kwargs + "from_defaults_only": "defaults_default", + "from_kwargs_only": "kwargs_default", + } + return {} + + TestComponent.render(kwargs={}) + assert did_call_context + + def test_defaults_from_defaults_and_kwargs_dataclass(self): + did_call_context = False + + class TestComponent(Component): + template = "" + + @dataclass + class Kwargs: + from_defaults_only: str + variable: str = "from_kwargs" + from_kwargs_only: str = "kwargs_default" + + class Defaults: + variable = "from_defaults" + from_defaults_only = "defaults_default" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal did_call_context + did_call_context = True + + assert self.raw_kwargs == { + "variable": "from_kwargs", # Overridden by Kwargs + "from_defaults_only": "defaults_default", + "from_kwargs_only": "kwargs_default", + } + return {} + + TestComponent.render(kwargs={}) + assert did_call_context + + def test_defaults_from_defaults_and_kwargs_other_class(self): + did_call_context = False + + class CustomKwargs: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + self._kwargs = kwargs + + def _asdict(self): + return self._kwargs + + class TestComponent(Component): + template = "" + + class Kwargs(CustomKwargs): + variable: str = "from_kwargs" + + class Defaults: + variable = "from_defaults" + from_defaults_only = "defaults_default" + + def get_template_data(self, args, kwargs, slots, context): + nonlocal did_call_context + did_call_context = True + + assert self.raw_kwargs == { + "variable": "from_defaults", # No override + "from_defaults_only": "defaults_default", + } + return {} + + TestComponent.render(kwargs={}) + assert did_call_context + + +@djc_test +class TestGetComponentDefaults: + def test_defaults_with_factory(self): + class MyComponent(Component): + template = "" + + class Defaults: + val = "static" + factory_val = Default(lambda: "from_factory") + + defaults = get_component_defaults(MyComponent) + assert defaults == { + "val": "static", + "factory_val": "from_factory", + } + + def test_kwargs_dataclass_with_factory(self): + class MyComponent(Component): + template = "" + + @dataclass + class Kwargs: + val: str = "static" + factory_val: str = field(default_factory=lambda: "from_factory") + + defaults = get_component_defaults(MyComponent) + assert defaults == { + "val": "static", + "factory_val": "from_factory", + } + + def test_defaults_and_kwargs_overrides_with_factories(self): + class MyComponent(Component): + template = "" + + @dataclass + class Kwargs: + val_both: str = field(default_factory=lambda: "from_kwargs_factory") + val_kwargs: str = field(default_factory=lambda: "kwargs_only") + + class Defaults: + val_both = Default(lambda: "from_defaults_factory") + val_defaults = Default(lambda: "defaults_only") + + defaults = get_component_defaults(MyComponent) + assert defaults == { + "val_both": "from_kwargs_factory", + "val_kwargs": "kwargs_only", + "val_defaults": "defaults_only", + } + + def test_kwargs_namedtuple_with_defaults(self): + class MyComponent(Component): + template = "" + + class Kwargs(NamedTuple): + val_no_default: int + val_defaults: str + val_kwargs: str = "kwargs_default" + + class Defaults: + val_defaults = "defaults_default" + + defaults = get_component_defaults(component=MyComponent) + assert defaults == { + "val_kwargs": "kwargs_default", + "val_defaults": "defaults_default", + } diff --git a/tests/test_component_dynamic.py b/tests/test_component_dynamic.py index 3f613d84..3e248188 100644 --- a/tests/test_component_dynamic.py +++ b/tests/test_component_dynamic.py @@ -22,10 +22,7 @@ class TestDynamicComponent: class Kwargs: variable: str - variable2: str - - class Defaults: - variable2 = "default" + variable2: str = "default" def get_template_data(self, args, kwargs: Kwargs, slots, context): return {