From f07818fc7d71ac7a25fcc6f510ce290292d204a9 Mon Sep 17 00:00:00 2001 From: Juro Oravec Date: Mon, 31 Mar 2025 10:38:41 +0200 Subject: [PATCH] feat: allow to set defaults (#1072) * feat: allow to set defaults * docs: update changelog * refactor: fix new linter errors --- CHANGELOG.md | 33 ++++ benchmarks/benchmark_templating.py | 1 - docs/concepts/fundamentals/.nav.yml | 1 + .../fundamentals/component_defaults.md | 133 +++++++++++++++ .../parametrising_components.md | 39 ++++- docs/reference/api.md | 4 + docs/reference/template_tags.md | 2 +- src/django_components/__init__.py | 2 + src/django_components/app_settings.py | 3 +- src/django_components/component.py | 15 +- src/django_components/extensions/defaults.py | 157 ++++++++++++++++++ src/django_components/util/tag_parser.py | 7 - tests/test_command_ext.py | 23 +-- tests/test_component.py | 4 +- tests/test_component_defaults.py | 147 ++++++++++++++++ tests/test_extension.py | 18 +- 16 files changed, 553 insertions(+), 36 deletions(-) create mode 100644 docs/concepts/fundamentals/component_defaults.md create mode 100644 src/django_components/extensions/defaults.py create mode 100644 tests/test_component_defaults.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d93ee0..0ff2fb20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ #### Feat +- Add defaults for the component inputs with the `Component.Defaults` nested class. Defaults + are applied if the argument is not given, or if it set to `None`. + + For lists, dictionaries, or other objects, wrap the value in `Default()` class to mark it as a factory + function: + + ```python + from django_components import Default + + class Table(Component): + class Defaults: + position = "left" + width = "200px" + options = Default(lambda: ["left", "right", "center"]) + + def get_context_data(self, position, width, options): + return { + "position": position, + "width": width, + "options": options, + } + + # `position` is used as given, `"right"` + # `width` uses default because it's `None` + # `options` uses default because it's missing + Table.render( + kwargs={ + "position": "right", + "width": None, + } + ) + ``` + - `{% html_attrs %}` now offers a Vue-like granular control over `class` and `style` HTML attributes, where each class name or style property can be managed separately. diff --git a/benchmarks/benchmark_templating.py b/benchmarks/benchmark_templating.py index 91a7d4ba..779c70aa 100644 --- a/benchmarks/benchmark_templating.py +++ b/benchmarks/benchmark_templating.py @@ -140,7 +140,6 @@ def prepare_templating_benchmark( context_mode: DjcContextMode, imports_only: bool = False, ): - global do_render setup_script = _get_templating_script(renderer, size, context_mode, imports_only) # If we're testing the startup time, then the setup is actually the tested code diff --git a/docs/concepts/fundamentals/.nav.yml b/docs/concepts/fundamentals/.nav.yml index 0dc243bd..8f23baf6 100644 --- a/docs/concepts/fundamentals/.nav.yml +++ b/docs/concepts/fundamentals/.nav.yml @@ -3,6 +3,7 @@ nav: - Single-file components: single_file_components.md - Components in Python: components_in_python.md - Accessing component inputs: access_component_input.md + - Component defaults: component_defaults.md - Component context and scope: component_context_scope.md - Template tag syntax: template_tag_syntax.md - Slots: slots.md diff --git a/docs/concepts/fundamentals/component_defaults.md b/docs/concepts/fundamentals/component_defaults.md new file mode 100644 index 00000000..90580b5f --- /dev/null +++ b/docs/concepts/fundamentals/component_defaults.md @@ -0,0 +1,133 @@ +When a component is being rendered, the component inputs are passed to various methods like +[`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), +[`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), +or [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data). + +It can be cumbersome to specify default values for each input in each method. + +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. + +### Defining defaults + +To define defaults for a component, you create a nested `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. + +```py +from django_components import Component, Default, register + +@register("my_table") +class MyTable(Component): + + class Defaults: + position = "left" + selected_items = Default(lambda: [1, 2, 3]) + + def get_context_data(self, position, selected_items): + return { + "position": position, + "selected_items": selected_items, + } + + ... +``` + +In this example, `position` is a simple default value, while `selected_items` uses a factory function wrapped in `Default` to ensure a new list is created each time the default is used. + +Now, when we render the component, the defaults will be applied: + +```django +{% component "my_table" position="right" / %} +``` + +In this case: + +- `position` input is set to `right`, so no defaults applied +- `selected_items` is not set, so it will be set to `[1, 2, 3]`. + +Same applies to rendering the Component in Python with the +[`render()`](../../../reference/api#django_components.Component.render) method: + +```py +MyTable.render( + kwargs={ + "position": "right", + "selected_items": None, + }, +) +``` + +Notice that we've set `selected_items` to `None`. `None` values are treated as missing values, +and so `selected_items` will be set to `[1, 2, 3]`. + +!!! warning + + The defaults are aplied only to keyword arguments. They are NOT applied to positional arguments! + +### Default factories + +For objects such as lists, dictionaries or other instances, you have to be careful - if you simply set a default value, this instance will be shared across all instances of the component! + +```py +from django_components import Component + +class MyTable(Component): + class Defaults: + # All instances will share the same list! + selected_items = [1, 2, 3] +``` + +To avoid this, you can use a factory function wrapped in `Default`. + +```py +from django_components import Component, Default + +class MyTable(Component): + class Defaults: + # A new list is created for each instance + selected_items = Default(lambda: [1, 2, 3]) +``` + +This is similar to how the dataclass fields work. + +In fact, you can also use the dataclass's [`field`](https://docs.python.org/3/library/dataclasses.html#dataclasses.field) function to define the factories: + +```py +from dataclasses import field +from django_components import Component + +class MyTable(Component): + class Defaults: + selected_items = field(default_factory=lambda: [1, 2, 3]) +``` + +### Accessing defaults + +Since the defaults are defined on the component class, you can access the defaults for a component with the `Component.Defaults` property. + +So if we have a component like this: + +```py +from django_components import Component, Default, register + +@register("my_table") +class MyTable(Component): + + class Defaults: + position = "left" + selected_items = Default(lambda: [1, 2, 3]) + + def get_context_data(self, position, selected_items): + return { + "position": position, + "selected_items": selected_items, + } +``` + +We can access individual defaults like this: + +```py +print(MyTable.Defaults.position) +print(MyTable.Defaults.selected_items) +``` diff --git a/docs/getting_started/parametrising_components.md b/docs/getting_started/parametrising_components.md index c5c4ddb9..5b787f3d 100644 --- a/docs/getting_started/parametrising_components.md +++ b/docs/getting_started/parametrising_components.md @@ -68,7 +68,7 @@ from django_components import Component, register class Calendar(Component): template_file = "calendar.html" ... - def get_context_data(self, date: date, extra_class: str | None = None): + def get_context_data(self, date: date, extra_class: str = "text-blue"): return { "date": date, "extra_class": extra_class, @@ -197,7 +197,7 @@ def to_workweek_date(d: date): class Calendar(Component): template_file = "calendar.html" ... - def get_context_data(self, date: date, extra_class: str | None = None): + def get_context_data(self, date: date, extra_class: str = "text-blue"): workweek_date = to_workweek_date(date) # <--- new return { "date": workweek_date, # <--- changed @@ -220,3 +220,38 @@ the parametrized version of the component: --- Next, you will learn [how to use slots give your components even more flexibility ➡️](./adding_slots.md) + +### 5. Add defaults + +In our example, we've set the `extra_class` to default to `"text-blue"` by setting it in the +[`get_context_data()`](../../reference/api#django_components.Component.get_context_data) +method. + +However, you may want to use the same default value in multiple methods, like +[`get_js_data()`](../../reference/api#django_components.Component.get_js_data) +or [`get_css_data()`](../../reference/api#django_components.Component.get_css_data). + +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 +[`Component`](../../reference/api#django_components.Component) class. +Each attribute in the `Defaults` class represents a default value for a corresponding input. + +```py +from django_components import Component, Default, register + +@register("calendar") +class Calendar(Component): + template_file = "calendar.html" + + class Defaults: # <--- new + extra_class = "text-blue" + + def get_context_data(self, date: date, extra_class: str): # <--- changed + workweek_date = to_workweek_date(date) + return { + "date": workweek_date, + "extra_class": extra_class, + } +``` diff --git a/docs/reference/api.md b/docs/reference/api.md index 700df9e0..fbda4fca 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -75,6 +75,10 @@ options: show_if_no_docstring: true +::: django_components.Default + options: + show_if_no_docstring: true + ::: django_components.EmptyDict options: show_if_no_docstring: true diff --git a/docs/reference/template_tags.md b/docs/reference/template_tags.md index bce47a14..357d2b95 100644 --- a/docs/reference/template_tags.md +++ b/docs/reference/template_tags.md @@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert -See source code +See source code diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py index b60abda5..b5df591d 100644 --- a/src/django_components/__init__.py +++ b/src/django_components/__init__.py @@ -40,6 +40,7 @@ from django_components.extension import ( OnComponentInputContext, OnComponentDataContext, ) +from django_components.extensions.defaults import Default from django_components.extensions.view import ComponentView from django_components.library import TagProtectedError from django_components.node import BaseNode, template_tag @@ -88,6 +89,7 @@ __all__ = [ "component_formatter", "component_shorthand_formatter", "ContextBehavior", + "Default", "DynamicComponent", "EmptyTuple", "EmptyDict", diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index 05fbd26d..66dd2b1b 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -750,9 +750,10 @@ class InternalSettings: ) # Prepend built-in extensions + from django_components.extensions.defaults import DefaultsExtension from django_components.extensions.view import ViewExtension - extensions = [ViewExtension] + list(extensions) + extensions = [DefaultsExtension, ViewExtension] + list(extensions) # Extensions may be passed in either as classes or import strings. extension_instances: List["ComponentExtension"] = [] diff --git a/src/django_components/component.py b/src/django_components/component.py index b12ae0f2..41d33ab5 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -962,6 +962,8 @@ class Component( - `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`, or to the end of the `` tag. CSS dependencies are inserted into `{% component_css_dependencies %}`, or the end of the `` tag. + - `"fragment"` - `{% component_js_dependencies %}` and `{% component_css_dependencies %}`, + are ignored, and a script that loads the JS and CSS dependencies is appended to the HTML. - `request` - The request object. This is only required when needing to use RequestContext, e.g. to enable template `context_processors`. @@ -1030,6 +1032,8 @@ class Component( - `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`, or to the end of the `` tag. CSS dependencies are inserted into `{% component_css_dependencies %}`, or the end of the `` tag. + - `"fragment"` - `{% component_js_dependencies %}` and `{% component_css_dependencies %}`, + are ignored, and a script that loads the JS and CSS dependencies is appended to the HTML. - `render_dependencies` - Set this to `False` if you want to insert the resulting HTML into another component. - `request` - The request object. This is only required when needing to use RequestContext, e.g. to enable template `context_processors`. @@ -1107,9 +1111,14 @@ class Component( request = parent_comp_ctx.request # Allow to provide no args/kwargs/slots/context - args = cast(ArgsType, args or ()) - kwargs = cast(KwargsType, kwargs or {}) - slots_untyped = self._normalize_slot_fills(slots or {}, escape_slots_content) + # NOTE: We make copies of args / kwargs / slots, so that plugins can modify them + # without affecting the original values. + args = cast(ArgsType, list(args) if args is not None else ()) + kwargs = cast(KwargsType, dict(kwargs) if kwargs is not None else {}) + slots_untyped = self._normalize_slot_fills( + dict(slots) if slots is not None else {}, + escape_slots_content, + ) slots = cast(SlotsType, slots_untyped) # Use RequestContext if request is provided, so that child non-component template tags # can access the request object too. diff --git a/src/django_components/extensions/defaults.py b/src/django_components/extensions/defaults.py new file mode 100644 index 00000000..e356c02b --- /dev/null +++ b/src/django_components/extensions/defaults.py @@ -0,0 +1,157 @@ +import sys +from dataclasses import MISSING, Field, dataclass +from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Type +from weakref import WeakKeyDictionary + +from django_components.extension import ComponentExtension, OnComponentClassCreatedContext, OnComponentInputContext + +if TYPE_CHECKING: + from django_components.component import Component + + +# NOTE: `WeakKeyDictionary` is NOT a generic pre-3.9 +if sys.version_info >= (3, 9): + ComponentDefaultsCache = WeakKeyDictionary[Type["Component"], List["ComponentDefaultField"]] +else: + ComponentDefaultsCache = WeakKeyDictionary + + +defaults_by_component: ComponentDefaultsCache = WeakKeyDictionary() + + +@dataclass +class Default: + """ + Use this class to mark a field on the `Component.Defaults` class as a factory. + + Read more about [Component defaults](../../concepts/fundamentals/component_defaults). + + **Example:** + + ```py + from django_components import Default + + class MyComponent(Component): + class Defaults: + # Plain value doesn't need a factory + position = "left" + # Lists and dicts need to be wrapped in `Default` + # Otherwise all instances will share the same value + selected_items = Default(lambda: [1, 2, 3]) + ``` + """ + + value: Callable[[], Any] + + +class ComponentDefaultField(NamedTuple): + """Internal representation of a field on the `Defaults` class.""" + + key: str + value: Any + is_factory: bool + + +# 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 + + 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. + if default_field_key.startswith("__") or default_field_key == "component_class": + continue + + default_field = getattr(defaults, default_field_key) + + # 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 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 = default_field + is_factory = False + + field_data = ComponentDefaultField( + key=default_field_key, + value=field_value, + is_factory=is_factory, + ) + defaults_fields.append(field_data) + + return defaults_fields + + +def _apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None: + """ + Apply the defaults from `Component.Defaults` to the given `kwargs`. + + Defaults are applied only to missing or `None` values. + """ + for default_field in defaults: + # Defaults are applied only to missing or `None` values + given_value = kwargs.get(default_field.key, None) + if given_value is not None: + continue + + if default_field.is_factory: + default_value = default_field.value() + else: + default_value = default_field.value + + kwargs[default_field.key] = default_value + + +class DefaultsExtension(ComponentExtension): + """ + This extension adds a nested `Defaults` class to each `Component`. + + This nested `Defaults` class is used to set default values for the component's kwargs. + + **Example:** + + ```py + from django_components import Component, Default + + class MyComponent(Component): + class Defaults: + position = "left" + # Factory values need to be wrapped in `Default` + selected_items = Default(lambda: [1, 2, 3]) + ``` + + This extension is automatically added to all components. + """ + + name = "defaults" + + # Preprocess the `Component.Defaults` class, if given, so we don't have to do it + # 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) + + # Apply defaults to missing or `None` values in `kwargs` + def on_component_input(self, ctx: OnComponentInputContext) -> None: + defaults = defaults_by_component.get(ctx.component_cls, None) + if defaults is None: + return + + _apply_defaults(ctx.kwargs, defaults) diff --git a/src/django_components/util/tag_parser.py b/src/django_components/util/tag_parser.py index 7d02b0a5..c25a47df 100644 --- a/src/django_components/util/tag_parser.py +++ b/src/django_components/util/tag_parser.py @@ -447,7 +447,6 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]: return False def taken_n(n: int) -> str: - nonlocal index result = text[index : index + n] # noqa: E203 add_token(result) return result @@ -457,9 +456,6 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]: tokens: Union[List[str], Tuple[str, ...]], ignore: Optional[Sequence[str]] = None, ) -> str: - nonlocal index - nonlocal text - result = "" while not is_at_end(): char = text[index] @@ -483,9 +479,6 @@ def parse_tag(text: str, parser: Optional[Parser]) -> Tuple[str, List[TagAttr]]: # tag_name = take_while([" ", "\t", "\n", "\r", "\f"]) def take_while(tokens: Union[List[str], Tuple[str, ...]]) -> str: - nonlocal index - nonlocal text - result = "" while not is_at_end(): char = text[index] diff --git a/tests/test_command_ext.py b/tests/test_command_ext.py index f4464733..1d11559d 100644 --- a/tests/test_command_ext.py +++ b/tests/test_command_ext.py @@ -97,7 +97,7 @@ class TestExtensionsListCommand: call_command("components", "ext", "list") output = out.getvalue() - assert output.strip() == "name\n====\nview" + assert output.strip() == "name \n========\ndefaults\nview" @djc_test( components_settings={"extensions": [EmptyExtension, DummyExtension]}, @@ -108,7 +108,7 @@ class TestExtensionsListCommand: call_command("components", "ext", "list") output = out.getvalue() - assert output.strip() == "name \n=====\nview \nempty\ndummy" + assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy" @djc_test( components_settings={"extensions": [EmptyExtension, DummyExtension]}, @@ -119,7 +119,7 @@ class TestExtensionsListCommand: call_command("components", "ext", "list", "--all") output = out.getvalue() - assert output.strip() == "name \n=====\nview \nempty\ndummy" + assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy" @djc_test( components_settings={"extensions": [EmptyExtension, DummyExtension]}, @@ -130,7 +130,7 @@ class TestExtensionsListCommand: call_command("components", "ext", "list", "--columns", "name") output = out.getvalue() - assert output.strip() == "name \n=====\nview \nempty\ndummy" + assert output.strip() == "name \n========\ndefaults\nview \nempty \ndummy" @djc_test( components_settings={"extensions": [EmptyExtension, DummyExtension]}, @@ -141,7 +141,7 @@ class TestExtensionsListCommand: call_command("components", "ext", "list", "--simple") output = out.getvalue() - assert output.strip() == "view \nempty\ndummy" + assert output.strip() == "defaults\nview \nempty \ndummy" @djc_test @@ -159,18 +159,19 @@ class TestExtensionsRunCommand: output == dedent( f""" - usage: components ext run [-h] {{view,empty,dummy}} ... + usage: components ext run [-h] {{defaults,view,empty,dummy}} ... Run a command added by an extension. {OPTIONS_TITLE}: - -h, --help show this help message and exit + -h, --help show this help message and exit subcommands: - {{view,empty,dummy}} - view Run commands added by the 'view' extension. - empty Run commands added by the 'empty' extension. - dummy Run commands added by the 'dummy' extension. + {{defaults,view,empty,dummy}} + defaults Run commands added by the 'defaults' extension. + view Run commands added by the 'view' extension. + empty Run commands added by the 'empty' extension. + dummy Run commands added by the 'dummy' extension. """ ).lstrip() ) diff --git a/tests/test_component.py b/tests/test_component.py index cafdbc02..f68c98c7 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -293,7 +293,7 @@ class TestComponent: class TestComponent(Component): @no_type_check def get_context_data(self, var1, var2, variable, another, **attrs): - assert self.input.args == (123, "str") + assert self.input.args == [123, "str"] assert self.input.kwargs == {"variable": "test", "another": 1} assert isinstance(self.input.context, Context) assert list(self.input.slots.keys()) == ["my_slot"] @@ -305,7 +305,7 @@ class TestComponent: @no_type_check def get_template(self, context): - assert self.input.args == (123, "str") + assert self.input.args == [123, "str"] assert self.input.kwargs == {"variable": "test", "another": 1} assert isinstance(self.input.context, Context) assert list(self.input.slots.keys()) == ["my_slot"] diff --git a/tests/test_component_defaults.py b/tests/test_component_defaults.py new file mode 100644 index 00000000..0ba7da80 --- /dev/null +++ b/tests/test_component_defaults.py @@ -0,0 +1,147 @@ +from dataclasses import field +from typing import Any + +from django.template import Context + +from django_components import Component, Default + +from django_components.testing import djc_test +from .testutils import setup_test_config + +setup_test_config({"autodiscover": False}) + + +@djc_test +class TestComponentDefaults: + def test_input_defaults(self): + did_call_context = False + + class TestComponent(Component): + template = "" + + class Defaults: + variable = "test" + another = 1 + extra = "extra" + fn = lambda: "fn_as_val" # noqa: E731 + + def get_context_data(self, arg1: Any, variable: Any, another: Any, **attrs: Any): + nonlocal did_call_context + did_call_context = True + + # 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.kwargs == { + "variable": "test", # User-given + "another": 1, # Default because missing + "extra": "extra", # Default because `None` was given + "fn": self.Defaults.fn, # Default because missing + } + assert isinstance(self.input.context, Context) + + return { + "variable": variable, + } + + TestComponent.render( + args=(123,), + kwargs={"variable": "test", "extra": None}, + slots={"my_slot": "MY_SLOT"}, + ) + + assert did_call_context + + def test_factory_from_class(self): + did_call_context = False + + class TestComponent(Component): + template = "" + + class Defaults: + variable = "test" + fn = Default(lambda: "fn_as_factory") + + def get_context_data(self, variable: Any, **attrs: Any): + nonlocal did_call_context + did_call_context = True + + assert self.input.kwargs == { + "variable": "test", # User-given + "fn": "fn_as_factory", # Default because missing + } + assert isinstance(self.input.context, Context) + + return { + "variable": variable, + } + + TestComponent.render( + kwargs={"variable": "test"}, + ) + + assert did_call_context + + def test_factory_from_dataclass_field_value(self): + did_call_context = False + + class TestComponent(Component): + template = "" + + class Defaults: + variable = "test" + fn = field(default=lambda: "fn_as_factory") + + def get_context_data(self, variable: Any, **attrs: Any): + nonlocal did_call_context + did_call_context = True + + assert self.input.kwargs == { + "variable": "test", # User-given + # NOTE: NOT a factory, because it was set as `field(default=...)` + "fn": self.Defaults.fn.default, # type: ignore[attr-defined] + } + assert isinstance(self.input.context, Context) + + return { + "variable": variable, + } + + TestComponent.render( + kwargs={"variable": "test"}, + ) + + assert did_call_context + + def test_factory_from_dataclass_field_factory(self): + did_call_context = False + + class TestComponent(Component): + template = "" + + class Defaults: + variable = "test" + fn = field(default_factory=lambda: "fn_as_factory") + + def get_context_data(self, variable: Any, **attrs: Any): + nonlocal did_call_context + did_call_context = True + + assert self.input.kwargs == { + "variable": "test", # User-given + # NOTE: IS a factory, because it was set as `field(default_factory=...)` + "fn": "fn_as_factory", # Default because missing + } + assert isinstance(self.input.context, Context) + + return { + "variable": variable, + } + + TestComponent.render( + kwargs={"variable": "test"}, + ) + + assert did_call_context diff --git a/tests/test_extension.py b/tests/test_extension.py index bbadee75..3206bfed 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -20,6 +20,7 @@ from django_components.extension import ( OnComponentInputContext, OnComponentDataContext, ) +from django_components.extensions.defaults import DefaultsExtension from django_components.extensions.view import ViewExtension from django_components.testing import djc_test @@ -125,9 +126,10 @@ def with_registry(on_created: Callable): class TestExtension: @djc_test(components_settings={"extensions": [DummyExtension]}) def test_extensions_setting(self): - assert len(app_settings.EXTENSIONS) == 2 - assert isinstance(app_settings.EXTENSIONS[0], ViewExtension) - assert isinstance(app_settings.EXTENSIONS[1], DummyExtension) + assert len(app_settings.EXTENSIONS) == 3 + assert isinstance(app_settings.EXTENSIONS[0], DefaultsExtension) + assert isinstance(app_settings.EXTENSIONS[1], ViewExtension) + assert isinstance(app_settings.EXTENSIONS[2], DummyExtension) @djc_test(components_settings={"extensions": [DummyExtension]}) def test_access_component_from_extension(self): @@ -150,7 +152,7 @@ class TestExtension: class TestExtensionHooks: @djc_test(components_settings={"extensions": [DummyExtension]}) def test_component_class_lifecycle_hooks(self): - extension = cast(DummyExtension, app_settings.EXTENSIONS[1]) + extension = cast(DummyExtension, app_settings.EXTENSIONS[2]) assert len(extension.calls["on_component_class_created"]) == 0 assert len(extension.calls["on_component_class_deleted"]) == 0 @@ -182,7 +184,7 @@ class TestExtensionHooks: @djc_test(components_settings={"extensions": [DummyExtension]}) def test_registry_lifecycle_hooks(self): - extension = cast(DummyExtension, app_settings.EXTENSIONS[1]) + extension = cast(DummyExtension, app_settings.EXTENSIONS[2]) assert len(extension.calls["on_registry_created"]) == 0 assert len(extension.calls["on_registry_deleted"]) == 0 @@ -219,7 +221,7 @@ class TestExtensionHooks: return {"name": name} registry.register("test_comp", TestComponent) - extension = cast(DummyExtension, app_settings.EXTENSIONS[1]) + extension = cast(DummyExtension, app_settings.EXTENSIONS[2]) # Verify on_component_registered was called assert len(extension.calls["on_component_registered"]) == 1 @@ -257,14 +259,14 @@ class TestExtensionHooks: test_slots = {"content": "Some content"} TestComponent.render(context=test_context, args=("arg1", "arg2"), kwargs={"name": "Test"}, slots=test_slots) - extension = cast(DummyExtension, app_settings.EXTENSIONS[1]) + extension = cast(DummyExtension, app_settings.EXTENSIONS[2]) # Verify on_component_input was called with correct args assert len(extension.calls["on_component_input"]) == 1 input_call: OnComponentInputContext = extension.calls["on_component_input"][0] assert input_call.component_cls == TestComponent assert isinstance(input_call.component_id, str) - assert input_call.args == ("arg1", "arg2") + assert input_call.args == ["arg1", "arg2"] assert input_call.kwargs == {"name": "Test"} assert len(input_call.slots) == 1 assert isinstance(input_call.slots["content"], Slot)