|
1 |
| -from django_components import ComponentExtension |
2 |
| -from django_components.extension import ( |
3 |
| - OnComponentDataContext, |
4 |
| - OnComponentInputContext, |
5 |
| -) |
| 1 | +from typing import Any, Optional, Set |
6 | 2 |
|
7 |
| -from djc_pydantic.validation import get_component_typing, validate_type |
| 3 | +from django_components import ComponentExtension, ComponentNode, OnComponentInputContext # noqa: F401 |
| 4 | +from pydantic import BaseModel |
8 | 5 |
|
9 | 6 |
|
10 | 7 | class PydanticExtension(ComponentExtension):
|
11 | 8 | """
|
12 | 9 | A Django component extension that integrates Pydantic for input and data validation.
|
13 | 10 |
|
14 |
| - This extension uses the types defined on the component's class to validate the inputs |
15 |
| - and outputs of Django components. |
16 |
| -
|
17 |
| - The following are validated: |
18 |
| -
|
19 |
| - - Inputs: |
20 |
| -
|
21 |
| - - `args` |
22 |
| - - `kwargs` |
23 |
| - - `slots` |
24 |
| -
|
25 |
| - - Outputs (data returned from): |
26 |
| -
|
27 |
| - - `get_context_data()` |
28 |
| - - `get_js_data()` |
29 |
| - - `get_css_data()` |
30 |
| -
|
31 |
| - Validation is done using Pydantic's `TypeAdapter`. As such, the following are expected: |
32 |
| -
|
33 |
| - - Positional arguments (`args`) should be defined as a `Tuple` type. |
34 |
| - - Other data (`kwargs`, `slots`, ...) are all objects or dictionaries, and can be defined |
35 |
| - using either `TypedDict` or Pydantic's `BaseModel`. |
| 11 | + NOTE: As of v0.140 the extension only ensures that the classes from django-components |
| 12 | + can be used with pydantic. For the actual validation, subclass `Kwargs`, `Slots`, etc |
| 13 | + from Pydantic's `BaseModel`, and use `ArgsBaseModel` for `Args`. |
36 | 14 |
|
37 | 15 | **Example:**
|
38 | 16 |
|
39 | 17 | ```python
|
40 |
| - MyCompArgs = Tuple[str, ...] |
41 |
| -
|
42 |
| - class MyCompKwargs(TypedDict): |
43 |
| - name: str |
44 |
| - age: int |
| 18 | + from django_components import Component, SlotInput |
| 19 | + from pydantic import BaseModel |
45 | 20 |
|
46 |
| - class MyCompSlots(TypedDict): |
47 |
| - header: SlotContent |
48 |
| - footer: SlotContent |
| 21 | + class MyComponent(Component): |
| 22 | + class Args(ArgsBaseModel): |
| 23 | + var1: str |
49 | 24 |
|
50 |
| - class MyCompData(BaseModel): |
51 |
| - data1: str |
52 |
| - data2: int |
| 25 | + class Kwargs(BaseModel): |
| 26 | + name: str |
| 27 | + age: int |
53 | 28 |
|
54 |
| - class MyCompJsData(BaseModel): |
55 |
| - js_data1: str |
56 |
| - js_data2: int |
| 29 | + class Slots(BaseModel): |
| 30 | + header: SlotInput |
| 31 | + footer: SlotInput |
57 | 32 |
|
58 |
| - class MyCompCssData(BaseModel): |
59 |
| - css_data1: str |
60 |
| - css_data2: int |
| 33 | + class TemplateData(BaseModel): |
| 34 | + data1: str |
| 35 | + data2: int |
61 | 36 |
|
62 |
| - class MyComponent(Component[MyCompArgs, MyCompKwargs, MyCompSlots, MyCompData, MyCompJsData, MyCompCssData]): |
63 |
| - ... |
64 |
| - ``` |
| 37 | + class JsData(BaseModel): |
| 38 | + js_data1: str |
| 39 | + js_data2: int |
65 | 40 |
|
66 |
| - To exclude a field from validation, set its type to `Any`. |
| 41 | + class CssData(BaseModel): |
| 42 | + css_data1: str |
| 43 | + css_data2: int |
67 | 44 |
|
68 |
| - ```python |
69 |
| - class MyComponent(Component[MyCompArgs, MyCompKwargs, MyCompSlots, Any, Any, Any]): |
70 | 45 | ...
|
71 | 46 | ```
|
72 | 47 | """
|
73 | 48 |
|
74 | 49 | name = "pydantic"
|
75 | 50 |
|
76 |
| - # Validate inputs to the component on `Component.render()` |
| 51 | + def __init__(self, *args: Any, **kwargs: Any): |
| 52 | + super().__init__(*args, **kwargs) |
| 53 | + self.rebuilt_comp_cls_ids: Set[str] = set() |
| 54 | + |
| 55 | + # If the Pydantic models reference other types with forward references, |
| 56 | + # the models may not be complete / "built" when the component is first loaded. |
| 57 | + # |
| 58 | + # Component classes may be created when other modules are still being imported, |
| 59 | + # so we have to wait until we start rendering components to ensure that everything |
| 60 | + # is loaded. |
| 61 | + # |
| 62 | + # At that point, we check for components whether they have any Pydantic models, |
| 63 | + # and whether those models need to be rebuilt. |
| 64 | + # |
| 65 | + # See https://errors.pydantic.dev/2.11/u/class-not-fully-defined |
| 66 | + # |
| 67 | + # Otherwise, we get an error like: |
| 68 | + # |
| 69 | + # ``` |
| 70 | + # pydantic.errors.PydanticUserError: `Slots` is not fully defined; you should define |
| 71 | + # `ComponentNode`, then call `Slots.model_rebuild()` |
| 72 | + # ``` |
77 | 73 | def on_component_input(self, ctx: OnComponentInputContext) -> None:
|
78 |
| - maybe_inputs = get_component_typing(ctx.component_cls) |
79 |
| - if maybe_inputs is None: |
80 |
| - return |
81 |
| - |
82 |
| - args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs |
83 |
| - comp_name = ctx.component_cls.__name__ |
84 |
| - |
85 |
| - # Validate args |
86 |
| - validate_type(ctx.args, args_type, f"Positional arguments of component '{comp_name}' failed validation") |
87 |
| - # Validate kwargs |
88 |
| - validate_type(ctx.kwargs, kwargs_type, f"Keyword arguments of component '{comp_name}' failed validation") |
89 |
| - # Validate slots |
90 |
| - validate_type(ctx.slots, slots_type, f"Slots of component '{comp_name}' failed validation") |
91 |
| - |
92 |
| - # Validate the data generated from `get_context_data()`, `get_js_data()` and `get_css_data()` |
93 |
| - def on_component_data(self, ctx: OnComponentDataContext) -> None: |
94 |
| - maybe_inputs = get_component_typing(ctx.component_cls) |
95 |
| - if maybe_inputs is None: |
| 74 | + if ctx.component.class_id in self.rebuilt_comp_cls_ids: |
96 | 75 | return
|
97 | 76 |
|
98 |
| - args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs |
99 |
| - comp_name = ctx.component_cls.__name__ |
100 |
| - |
101 |
| - # Validate data |
102 |
| - validate_type(ctx.context_data, data_type, f"Data of component '{comp_name}' failed validation") |
103 |
| - # Validate JS data |
104 |
| - validate_type(ctx.js_data, js_data_type, f"JS data of component '{comp_name}' failed validation") |
105 |
| - # Validate CSS data |
106 |
| - validate_type(ctx.css_data, css_data_type, f"CSS data of component '{comp_name}' failed validation") |
| 77 | + for name in ["Args", "Kwargs", "Slots", "TemplateData", "JsData", "CssData"]: |
| 78 | + cls: Optional[BaseModel] = getattr(ctx.component, name, None) |
| 79 | + if cls is None: |
| 80 | + continue |
| 81 | + |
| 82 | + if hasattr(cls, "__pydantic_complete__") and not cls.__pydantic_complete__: |
| 83 | + # When resolving forward references, Pydantic needs the module globals, |
| 84 | + # AKA a dict that resolves those forward references. |
| 85 | + # |
| 86 | + # There's 2 problems here - user may define their own types which may need |
| 87 | + # resolving. And we define the Slot type which needs to access `ComponentNode`. |
| 88 | + # |
| 89 | + # So as a solution, we provide to Pydantic a dictionary that contains globals |
| 90 | + # from both 1. the component file and 2. this file (where we import `ComponentNode`). |
| 91 | + mod = __import__(cls.__module__) |
| 92 | + module_globals = mod.__dict__ |
| 93 | + cls.model_rebuild(_types_namespace={**globals(), **module_globals}) |
| 94 | + |
| 95 | + self.rebuilt_comp_cls_ids.add(ctx.component.class_id) |
0 commit comments