diff --git a/CHANGELOG.md b/CHANGELOG.md index f44c6fc7..345ffeee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # Release notes +## v0.142.4 + +#### Refactor + +- 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. + + If they are not subclassing (nor `@dataclass`), these data classes will be automatically converted to `NamedTuples`: + + Before - the `Args`, `Kwargs`, and `Slots` (etc..) had to be NamedTuples, dataclasses, or Pydantic models: + + ```py + from typing import NamedTuple + from django_components import Component + + class Button(Component): + class Args(NamedTuple): + size: int + text: str + + class Kwargs(NamedTuple): + variable: str + maybe_var: Optional[int] = None + + class Slots(NamedTuple): + my_slot: Optional[SlotInput] = None + + def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context): + ... + ``` + + Now these classes are automatically converted to `NamedTuples` if they don't subclass anything else: + + ```py + class Button(Component): + class Args: # Same as `Args(NamedTuple)` + size: int + text: str + + class Kwargs: # Same as `Kwargs(NamedTuple)` + variable: str + maybe_var: Optional[int] = None + + class Slots: # Same as `Slots(NamedTuple)` + my_slot: Optional[SlotInput] = None + + def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context): + ... + ``` + +- 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. + ## v0.142.3 #### Fix diff --git a/README.md b/README.md index 36272b9b..ae3c595a 100644 --- a/README.md +++ b/README.md @@ -405,21 +405,21 @@ Avoid needless errors with [type hints and runtime input validation](https://dja To opt-in to input validation, define types for component's args, kwargs, slots, and more: ```py -from typing import NamedTuple, Optional +from typing import Optional from django.template import Context from django_components import Component, Slot, SlotInput class Button(Component): - class Args(NamedTuple): + class Args: size: int text: str - class Kwargs(NamedTuple): + class Kwargs: variable: str another: int maybe_var: Optional[int] = None # May be omitted - class Slots(NamedTuple): + class Slots: my_slot: Optional[SlotInput] = None another_slot: SlotInput diff --git a/docs/concepts/advanced/component_libraries.md b/docs/concepts/advanced/component_libraries.md index 07f0a40f..f509df41 100644 --- a/docs/concepts/advanced/component_libraries.md +++ b/docs/concepts/advanced/component_libraries.md @@ -101,7 +101,7 @@ For live examples, see the [Examples](../../examples/index.md). It's also a good idea to have a common prefix for your components, so they can be easily distinguished from users' components. In the example below, we use the prefix `my_` / `My`. ```djc_py - from typing import NamedTuple, Optional + from typing import Optional from django_components import Component, SlotInput, register, types from myapp.templatetags.mytags import comp_registry @@ -111,16 +111,16 @@ For live examples, see the [Examples](../../examples/index.md). @register("my_menu", registry=comp_registry) class MyMenu(Component): # Define the types - class Args(NamedTuple): + class Args: size: int text: str - class Kwargs(NamedTuple): + class Kwargs: vertical: Optional[bool] = None klass: Optional[str] = None style: Optional[str] = None - class Slots(NamedTuple): + class Slots: default: Optional[SlotInput] = None def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context): diff --git a/docs/concepts/advanced/extensions.md b/docs/concepts/advanced/extensions.md index 50d76cd4..0452e4bd 100644 --- a/docs/concepts/advanced/extensions.md +++ b/docs/concepts/advanced/extensions.md @@ -65,12 +65,14 @@ E.g.: Each method of the nested classes has access to the `component` attribute, which points to the component instance. + This may be `None` if the methods does NOT run during the rendering. + ```python class MyTable(Component): class MyExtension: def get(self, request): # `self.component` points to the instance of `MyTable` Component. - return self.component.render_to_response(request=request) + return self.component.kwargs.some_input ``` ### Example: Component as View diff --git a/docs/concepts/advanced/hooks.md b/docs/concepts/advanced/hooks.md index ad9711f5..9555a44f 100644 --- a/docs/concepts/advanced/hooks.md +++ b/docs/concepts/advanced/hooks.md @@ -289,14 +289,14 @@ To implement this, we render the fallback slot in [`on_render()`](../../../refer and return it if an error occured: ```djc_py -from typing import NamedTuple, Optional +from typing import Optional from django.template import Context, Template from django.utils.safestring import mark_safe from django_components import Component, OnRenderGenerator, SlotInput, types class ErrorFallback(Component): - class Slots(NamedTuple): + class Slots: default: Optional[SlotInput] = None fallback: Optional[SlotInput] = None diff --git a/docs/concepts/fundamentals/component_defaults.md b/docs/concepts/fundamentals/component_defaults.md index a57daf45..2ca02af6 100644 --- a/docs/concepts/fundamentals/component_defaults.md +++ b/docs/concepts/fundamentals/component_defaults.md @@ -74,7 +74,7 @@ and so `selected_items` will be set to `[1, 2, 3]`. ```py class ProfileCard(Component): - class Kwargs(NamedTuple): + class Kwargs: show_details: bool = True ``` diff --git a/docs/concepts/fundamentals/html_js_css_variables.md b/docs/concepts/fundamentals/html_js_css_variables.md index b7164e06..dd3b4d05 100644 --- a/docs/concepts/fundamentals/html_js_css_variables.md +++ b/docs/concepts/fundamentals/html_js_css_variables.md @@ -15,7 +15,7 @@ Each method handles the data independently - you can define different data for t ```python class ProfileCard(Component): - class Kwargs(NamedTuple): + class Kwargs: user_id: int show_details: bool @@ -51,7 +51,7 @@ If [`get_template_data()`](../../../reference/api/#django_components.Component.g class ProfileCard(Component): template_file = "profile_card.html" - class Kwargs(NamedTuple): + class Kwargs: user_id: int show_details: bool @@ -182,10 +182,10 @@ class ProfileCard(Component): ```py class ProfileCard(Component): - class Args(NamedTuple): + class Args: user_id: int - class Kwargs(NamedTuple): + class Kwargs: show_details: bool # Access inputs directly as parameters @@ -228,10 +228,10 @@ class ProfileCard(Component): ```py class ProfileCard(Component): - class Args(NamedTuple): + class Args: user_id: int - class Kwargs(NamedTuple): + class Kwargs: show_details: bool def get_template_data(self, args: Args, kwargs: Kwargs, slots, context): @@ -320,7 +320,7 @@ from django_components import Component, Default, register @register("profile_card") class ProfileCard(Component): - class Kwargs(NamedTuple): + class Kwargs: show_details: bool class Defaults: @@ -344,7 +344,7 @@ class ProfileCard(Component): ```py class ProfileCard(Component): - class Kwargs(NamedTuple): + class Kwargs: show_details: bool = True ``` @@ -391,18 +391,18 @@ This will also validate the inputs at runtime, as the type classes will be insta Read more about [Component typing](../../fundamentals/typing_and_validation). ```python -from typing import NamedTuple, Optional +from typing import Optional from django_components import Component, SlotInput class Button(Component): - class Args(NamedTuple): + class Args: name: str - class Kwargs(NamedTuple): + class Kwargs: surname: str maybe_var: Optional[int] = None # May be omitted - class Slots(NamedTuple): + class Slots: my_slot: Optional[SlotInput] = None footer: SlotInput @@ -433,19 +433,18 @@ and [`CssData`](../../../reference/api/#django_components.Component.CssData) cla For each data method, you can either return a plain dictionary with the data, or an instance of the respective data class. ```python -from typing import NamedTuple from django_components import Component class Button(Component): - class TemplateData(NamedTuple): + class TemplateData( data1: str data2: int - class JsData(NamedTuple): + class JsData: js_data1: str js_data2: int - class CssData(NamedTuple): + class CssData: css_data1: str css_data2: int diff --git a/docs/concepts/fundamentals/render_api.md b/docs/concepts/fundamentals/render_api.md index d41708d4..4f8c1a16 100644 --- a/docs/concepts/fundamentals/render_api.md +++ b/docs/concepts/fundamentals/render_api.md @@ -92,7 +92,7 @@ With `Args` class: from django_components import Component class Table(Component): - class Args(NamedTuple): + class Args: page: int per_page: int @@ -137,7 +137,7 @@ With `Kwargs` class: from django_components import Component class Table(Component): - class Kwargs(NamedTuple): + class Kwargs: page: int per_page: int @@ -182,7 +182,7 @@ With `Slots` class: from django_components import Component, Slot, SlotInput class Table(Component): - class Slots(NamedTuple): + class Slots: header: SlotInput footer: SlotInput diff --git a/docs/concepts/fundamentals/rendering_components.md b/docs/concepts/fundamentals/rendering_components.md index dd66004e..fe3011d2 100644 --- a/docs/concepts/fundamentals/rendering_components.md +++ b/docs/concepts/fundamentals/rendering_components.md @@ -80,14 +80,13 @@ For a component to be renderable with the [`{% component %}`](../../../reference For example, if you register a component under the name `"button"`: ```python -from typing import NamedTuple from django_components import Component, register @register("button") class Button(Component): template_file = "button.html" - class Kwargs(NamedTuple): + class Kwargs: name: str job: str @@ -207,20 +206,20 @@ The [`Component.render()`](../../../reference/api/#django_components.Component.r This is the equivalent of calling the [`{% component %}`](../template_tags#component) tag. ```python -from typing import NamedTuple, Optional +from typing import Optional from django_components import Component, SlotInput class Button(Component): template_file = "button.html" - class Args(NamedTuple): + class Args: name: str - class Kwargs(NamedTuple): + class Kwargs: surname: str age: int - class Slots(NamedTuple): + class Slots: footer: Optional[SlotInput] = None def get_template_data(self, args, kwargs, slots, context): @@ -264,20 +263,20 @@ Any extra arguments are passed to the [`HttpResponse`](https://docs.djangoprojec constructor. ```python -from typing import NamedTuple, Optional +from typing import Optional from django_components import Component, SlotInput class Button(Component): template_file = "button.html" - class Args(NamedTuple): + class Args( name: str - class Kwargs(NamedTuple): + class Kwargs: surname: str age: int - class Slots(NamedTuple): + class Slots: footer: Optional[SlotInput] = None def get_template_data(self, args, kwargs, slots, context): @@ -486,19 +485,19 @@ and [`Slots`](../../../reference/api/#django_components.Component.Slots) classes Read more on [Typing and validation](../../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 diff --git a/docs/concepts/fundamentals/typing_and_validation.md b/docs/concepts/fundamentals/typing_and_validation.md index 59c36de0..70a99f07 100644 --- a/docs/concepts/fundamentals/typing_and_validation.md +++ b/docs/concepts/fundamentals/typing_and_validation.md @@ -20,20 +20,20 @@ that allow you to define the types of args, kwargs, slots, as well as the data r Use this to add type hints to your components, to validate the inputs at runtime, and to document them. ```py -from typing import NamedTuple, Optional +from typing import Optional from django.template import Context from django_components import Component, SlotInput class Button(Component): - class Args(NamedTuple): + class Args: size: int text: str - class Kwargs(NamedTuple): + class Kwargs: variable: str maybe_var: Optional[int] = None # May be omitted - class Slots(NamedTuple): + class Slots: my_slot: Optional[SlotInput] = None def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context): @@ -59,7 +59,7 @@ You can use [`Component.Args`](../../../reference/api#django_components.Componen [`Component.Kwargs`](../../../reference/api#django_components.Component.Kwargs), and [`Component.Slots`](../../../reference/api#django_components.Component.Slots) to type the component inputs. -When you set these classes, at render time the `args`, `kwargs`, and `slots` parameters of the data methods +When you set these input classes, the `args`, `kwargs`, and `slots` parameters of the data methods ([`get_template_data()`](../../../reference/api#django_components.Component.get_template_data), [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data)) @@ -80,7 +80,7 @@ or set them to `None`, the inputs will be passed as plain lists or dictionaries, and will not be validated. ```python -from typing_extensions import NamedTuple, TypedDict +from typing_extensions import TypedDict from django.template import Context from django_components import Component, Slot, SlotInput @@ -90,15 +90,15 @@ class ButtonFooterSlotData(TypedDict): # 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 maybe_var: Optional[int] = None # May be omitted - class Slots(NamedTuple): + class Slots: # Use `SlotInput` to allow slots to be given as `Slot` instance, # plain string, or a function that returns a string. my_slot: Optional[SlotInput] = None @@ -143,7 +143,7 @@ class Button(Component): Args = None Slots = None - class Kwargs(NamedTuple): + class Kwargs: name: str age: int @@ -195,19 +195,18 @@ If you omit the [`TemplateData`](../../../reference/api#django_components.Compon or set them to `None`, the validation and instantiation will be skipped. ```python -from typing import NamedTuple from django_components import Component class Button(Component): - class TemplateData(NamedTuple): + class TemplateData: data1: str data2: int - class JsData(NamedTuple): + class JsData: js_data1: str js_data2: int - class CssData(NamedTuple): + class CssData: css_data1: str css_data2: int @@ -233,19 +232,18 @@ class Button(Component): For each data method, you can either return a plain dictionary with the data, or an instance of the respective data class directly. ```python -from typing import NamedTuple from django_components import Component class Button(Component): - class TemplateData(NamedTuple): + class TemplateData: data1: str data2: int - class JsData(NamedTuple): + class JsData: js_data1: str js_data2: int - class CssData(NamedTuple): + class CssData: css_data1: str css_data2: int @@ -270,34 +268,63 @@ class Button(Component): ## Custom types -We recommend to use [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) -for the `Args` class, and [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple), +So far, we've defined the input classes like `Kwargs` as simple classes. + +The truth is that when these classes don't subclass anything else, +they are converted to `NamedTuples` behind the scenes. + +```py +class Table(Component): + class Kwargs: + name: str + age: int +``` + +is the same as: + +```py +class Table(Component): + class Kwargs(NamedTuple): + name: str + age: int +``` + +You can actually set these classes to anything you want - whether it's dataclasses, +[Pydantic models](https://docs.pydantic.dev/latest/concepts/models/), or custom classes: + +```py +from typing import NamedTuple, Optional +from django_components import Component, Optional +from pydantic import BaseModel + +class Button(Component): + class Args(NamedTuple): + size: int + text: str + + @dataclass + class Kwargs: + variable: str + maybe_var: Optional[int] = None + + class Slots(BaseModel): + my_slot: Optional[SlotInput] = None + + def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context): + ... +``` + +We recommend: + +- [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) +for the `Args` class +- [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple), [dataclasses](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), or [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/) for `Kwargs`, `Slots`, `TemplateData`, `JsData`, and `CssData` classes. However, you can use any class, as long as they meet the conditions below. -For example, here is how you can use [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/) -to validate the inputs at runtime. - -```py -from django_components import Component -from pydantic import BaseModel - -class Table(Component): - class Kwargs(BaseModel): - name: str - age: int - - def get_template_data(self, args, kwargs, slots, context): - assert isinstance(kwargs, Table.Kwargs) - -Table.render( - kwargs=Table.Kwargs(name="John", age=30), -) -``` - ### `Args` class The [`Args`](../../../reference/api#django_components.Component.Args) class @@ -390,7 +417,7 @@ As a workaround: ```py class Table(Component): - class Args(NamedTuple): + class Args: args: List[str] Table.render( @@ -402,7 +429,7 @@ As a workaround: ```py class Table(Component): - class Kwargs(NamedTuple): + class Kwargs: variable: str another: int # Pass any extra keys under `extra` @@ -422,17 +449,16 @@ As a workaround: To declare that a component accepts no args, kwargs, etc, define the types with no attributes using the `pass` keyword: ```py -from typing import NamedTuple from django_components import Component class Button(Component): - class Args(NamedTuple): + class Args: pass - class Kwargs(NamedTuple): + class Kwargs: pass - class Slots(NamedTuple): + class Slots: pass ``` @@ -459,14 +485,14 @@ In the example below, `ButtonExtra` inherits `Kwargs` from `Button`, but overrid from django_components import Component, Empty class Button(Component): - class Args(NamedTuple): + class Args: size: int - class Kwargs(NamedTuple): + class Kwargs: color: str class ButtonExtra(Button): - class Args(NamedTuple): + class Args: name: str size: int @@ -491,10 +517,10 @@ Compare the following: ```py class Button(Component): - class Args(NamedTuple): + class Args: size: int - class Kwargs(NamedTuple): + class Kwargs: color: str # Both `Args` and `Kwargs` are defined on the class @@ -624,23 +650,23 @@ The steps to migrate are: Thus, the code above will become: ```py -from typing import NamedTuple, Optional +from typing import Optional from django.template import Context from django_components import Component, Slot, SlotInput # The Component class does not take any generics class Button(Component): # Types are now defined inside the component class - class Args(NamedTuple): + class Args: size: int text: str - class Kwargs(NamedTuple): + class Kwargs: variable: str another: int maybe_var: Optional[int] = None # May be omitted - class Slots(NamedTuple): + class Slots: # Use `SlotInput` to allow slots to be given as `Slot` instance, # plain string, or a function that returns a string. my_slot: Optional[SlotInput] = None diff --git a/docs/examples/ab_testing/component.py b/docs/examples/ab_testing/component.py index 839fe956..88bd66ec 100644 --- a/docs/examples/ab_testing/component.py +++ b/docs/examples/ab_testing/component.py @@ -1,6 +1,6 @@ # ruff: noqa: S311 import random -from typing import NamedTuple, Optional +from typing import Optional from django_components import Component, register, types @@ -9,7 +9,7 @@ DESCRIPTION = "Dynamically render different component versions. Use for A/B test @register("offer_card_old") class OfferCardOld(Component): - class Kwargs(NamedTuple): + class Kwargs: savings_percent: int def get_template_data(self, args, kwargs, slots, context): @@ -45,7 +45,7 @@ class OfferCardNew(OfferCardOld): @register("offer_card") class OfferCard(Component): - class Kwargs(NamedTuple): + class Kwargs: savings_percent: int use_new_version: Optional[bool] = None diff --git a/docs/examples/analytics/component.py b/docs/examples/analytics/component.py index afc3b8d2..db97d356 100644 --- a/docs/examples/analytics/component.py +++ b/docs/examples/analytics/component.py @@ -1,4 +1,4 @@ -from typing import Dict, List, NamedTuple +from typing import Dict, List from django_components import Component, register, types @@ -14,7 +14,7 @@ error_rate = { @register("api_widget") class ApiWidget(Component): - class Kwargs(NamedTuple): + class Kwargs: simulate_error: bool = False def get_template_data(self, args, kwargs: Kwargs, slots, context): diff --git a/docs/examples/error_fallback/component.py b/docs/examples/error_fallback/component.py index ccc0d409..4659af9e 100644 --- a/docs/examples/error_fallback/component.py +++ b/docs/examples/error_fallback/component.py @@ -1,6 +1,5 @@ # ruff: noqa: S311 import random -from typing import NamedTuple from django_components import Component, register, types @@ -9,7 +8,7 @@ DESCRIPTION = "A component that catches errors and displays fallback content, si @register("weather_widget") class WeatherWidget(Component): - class Kwargs(NamedTuple): + class Kwargs: location: str simulate_error: bool = False diff --git a/docs/examples/form_grid/component.py b/docs/examples/form_grid/component.py index 4b704247..0dc92502 100644 --- a/docs/examples/form_grid/component.py +++ b/docs/examples/form_grid/component.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from django_components import Component, Slot, register, types @@ -9,7 +9,7 @@ DESCRIPTION = "Form that automatically arranges fields in a grid and generates l class FormGrid(Component): """Form that automatically arranges fields in a grid and generates labels.""" - class Kwargs(NamedTuple): + class Kwargs: editable: bool = True method: str = "post" form_content_attrs: Optional[dict] = None @@ -114,7 +114,7 @@ def prepare_form_grid(slots: Dict[str, Slot]): # Case: Component user didn't explicitly define how to render the label # We will create the label for the field automatically label = FormGridLabel.render( - kwargs=FormGridLabel.Kwargs(field_name=field_name), + kwargs=FormGridLabel.Kwargs(field_name=field_name), # type: ignore[call-arg] deps_strategy="ignore", ) @@ -134,7 +134,7 @@ class FormGridLabel(Component): """ - class Kwargs(NamedTuple): + class Kwargs: field_name: str title: Optional[str] = None diff --git a/docs/examples/form_submission/component.py b/docs/examples/form_submission/component.py index 410220ad..62267338 100644 --- a/docs/examples/form_submission/component.py +++ b/docs/examples/form_submission/component.py @@ -9,7 +9,7 @@ DESCRIPTION = "Handle the entire form submission flow in a single file and witho @register("thank_you_message") class ThankYouMessage(Component): - class Kwargs(NamedTuple): + class Kwargs: name: str def get_template_data(self, args, kwargs: Kwargs, slots, context): diff --git a/docs/examples/fragments/component.py b/docs/examples/fragments/component.py index ea10af75..b0479b37 100644 --- a/docs/examples/fragments/component.py +++ b/docs/examples/fragments/component.py @@ -1,5 +1,3 @@ -from typing import NamedTuple - from django_components import Component, register, types DESCRIPTION = "Use HTML fragments (partials) with HTMX, AlpineJS, or plain JS." @@ -9,7 +7,7 @@ DESCRIPTION = "Use HTML fragments (partials) with HTMX, AlpineJS, or plain JS." class SimpleFragment(Component): """A simple fragment with JS and CSS.""" - class Kwargs(NamedTuple): + class Kwargs: type: str template: types.django_html = """ @@ -37,7 +35,7 @@ class SimpleFragment(Component): class AlpineFragment(Component): """A fragment that defines an AlpineJS component.""" - class Kwargs(NamedTuple): + class Kwargs: type: str # The fragment is wrapped in `