Coverage for formkit_ninja / formkit_schema.py: 58.70%
287 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-20 04:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-20 04:40 +0000
1from __future__ import annotations
3import logging
4import warnings
5from html.parser import HTMLParser
6from typing import Annotated, Any, Literal, Type, TypeAlias, TypedDict, TypeVar, Union
8# Configure Pydantic to avoid forward reference issues
9import pydantic
10from pydantic import BaseModel, Field
12pydantic.BaseModel.Config.arbitrary_types_allowed = True
14"""
15This is a port of selected parts of the FormKit schema
16to Pydantic models.
17"""
20logger = logging.getLogger(__name__)
22HtmlAttrs = dict[str, str | dict[str, str]]
25# Node is defined below as a TypeAlias
27# Radio, Select, Autocomplete and Dropdown nodes have
28# these options
29OptionsType = str | list[dict[str, Any]] | list[str] | dict[str, str] | None
32class FormKitSchemaCondition(BaseModel):
33 node_type: Literal["condition"] = Field(default="condition", exclude=True)
34 if_condition: str = Field(..., alias="if")
35 then_condition: Any = Field(..., alias="then")
36 else_condition: Any | None = Field(None, alias="else")
39class FormKitSchemaMeta(BaseModel):
40 __root__: dict[str, str | float | int | bool | None]
43class FormKitTypeDefinition(BaseModel): ...
46class FormKitContextShape(BaseModel):
47 type: Literal["input", "list", "group"]
48 value: Any
49 _value: Any
52class FormKitListValue(BaseModel):
53 __root__: str | list[str] | list[dict[str, str]]
56class FormKitListStatement(BaseModel):
57 """
58 A full loop statement in tuple syntax. Can be read like "foreach value, key? in list"
59 A 2 or 2 element tuple of value, key, and list or value, list
60 """
62 __root__: tuple[str, float | int | str, list[FormKitListValue]]
65class FormKitSchemaAttributesCondition(BaseModel):
66 if_: str = Field(alias="if")
67 then_: FormKitAttributeValue = Field(alias="then")
68 else_: FormKitAttributeValue | None = Field(alias="else")
70 class Config:
71 allow_population_by_field_name = True
74class FormKitAttributeValue(BaseModel):
75 """
76 The possible value types of attributes (in the schema)
77 """
79 __root__: Any
82class FormKitSchemaAttributes(BaseModel):
83 __root__: dict[str, Any]
86class FormKitSchemaProps(BaseModel):
87 """
88 Properties available in all schema nodes.
89 """
91 # "ForwardRefs" do not work well with django-ninja.
92 # This would ideally be:
93 # children: str | list[FormKitSchemaProps] | FormKitSchemaCondition | None = Field(
94 # default_factory=list
95 # )
96 children: str | list[FormKitSchemaProps | str] | FormKitSchemaCondition | None = Field()
97 key: str | None
98 if_condition: str | None = Field(alias="if")
99 for_loop: FormKitListStatement | None = Field(alias="for")
100 bind: str | None
101 meta: FormKitSchemaMeta | None
103 # These are not formal parts of spec, but
104 # are attributes defined in ts as Record<string, any>
105 # id: str | uuid.UUID | None = Field(None)
106 id: str = Field(None)
107 name: str | None = Field(None)
108 label: str | None = Field(None)
109 help: str | None = Field(None)
110 validation: str | None = Field(None)
111 validationLabel: str | None = Field(None, alias="validation-label")
112 validationVisibility: str | None = Field(None, alias="validation-visibility")
113 validationMessages: str | dict[str, str] = Field(None, alias="validation-messages")
114 placeholder: str | None = Field(None)
115 value: str | None = Field(None)
116 prefixIcon: str | None = Field(None)
117 icon: str | None = Field(None)
118 title: str | None = Field(None)
119 classes: str | dict[str, str] | None = Field(None)
120 readonly: bool | None = Field(None)
121 sectionsSchema: dict[str, Any] | None = Field(None)
123 # Code Generation Source of Truth
124 django_field_type: str | None = Field(None, exclude=True)
125 django_field_args: dict[str, Any] = Field(default_factory=dict, exclude=True)
126 django_field_positional_args: list[Any] = Field(default_factory=list, exclude=True)
127 pydantic_field_type: str | None = Field(None, exclude=True)
128 extra_imports: list[str] = Field(default_factory=list, exclude=True)
129 validators: list[str] = Field(default_factory=list, exclude=True)
131 # FormKit allows arbitrary values, we do our best to represent these here
132 # Additional Props can be quite a complicated structure
133 additional_props: None | dict[str, str | dict[str, Any]] = Field(None)
135 class Config:
136 allow_population_by_field_name = True
138 def dict(self, *args, **kwargs):
139 # Set some sensible defaults for "to_dict"
140 if "by_alias" not in kwargs:
141 kwargs["by_alias"] = True
142 if "exclude_none" not in kwargs:
143 kwargs["exclude_none"] = True
144 _ = super().dict(*args, **kwargs)
146 # Merge additional_props if they exist
147 if "additional_props" in _:
148 additional = _["additional_props"]
149 if additional:
150 _.update(additional)
151 del _["additional_props"]
153 # Filter out empty strings
154 # We do this after merging additional_props so they are also cleaned
155 return {k: v for k, v in _.items() if v != ""}
158# We defined this after the model above as it's a circular reference
159ChildNodeType = str | list[FormKitSchemaProps | str] | FormKitSchemaCondition | None
162class TextNode(FormKitSchemaProps):
163 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
164 formkit: Literal["text"] = Field(default="text", alias="$formkit")
165 text: str | None
166 maxLength: int | None = Field(None, description="Maximum length of the text input")
169class TextAreaNode(FormKitSchemaProps):
170 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
171 formkit: Literal["textarea"] = Field(default="textarea", alias="$formkit")
172 text: str | None
175class DateNode(FormKitSchemaProps):
176 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
177 formkit: Literal["date"] = Field(default="date", alias="$formkit")
180class CurrencyNode(FormKitSchemaProps):
181 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
182 formkit: Literal["currency"] = Field(default="currency", alias="$formkit")
185class UuidNode(FormKitSchemaProps):
186 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
187 formkit: Literal["uuid"] = Field(default="uuid", alias="$formkit")
190class DatePickerNode(FormKitSchemaProps):
191 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
192 formkit: Literal["datepicker"] = Field(default="datepicker", alias="$formkit")
193 calendarIcon: str = "calendar"
194 format: str = "DD/MM/YY"
195 nextIcon: str = "angleRight"
196 prevIcon: str = "angleLeft"
197 minDateSource: str | None = Field(None, alias="_minDateSource", description="Field to use as min date")
198 maxDateSource: str | None = Field(None, alias="_maxDateSource", description="Field to use as max date")
199 disabledDays: str | None = Field(None, description="Function to disable days")
202class CheckBoxNode(FormKitSchemaProps):
203 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
204 formkit: Literal["checkbox"] = Field(default="checkbox", alias="$formkit")
207class NumberNode(FormKitSchemaProps):
208 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
209 formkit: Literal["number"] = Field(default="number", alias="$formkit")
210 text: str | None
211 max: int | None = None
212 min: int | str | None = None
213 step: int | str | None = None
216class PasswordNode(FormKitSchemaProps):
217 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
218 formkit: Literal["password"] = Field(default="password", alias="$formkit")
219 name: str | None
222class HiddenNode(FormKitSchemaProps):
223 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
224 formkit: Literal["hidden"] = Field(default="hidden", alias="$formkit")
227class RadioNode(FormKitSchemaProps):
228 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
229 formkit: Literal["radio"] = Field(default="radio", alias="$formkit")
230 name: str | None
231 options: OptionsType = Field(None)
234class SelectNode(FormKitSchemaProps):
235 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
236 formkit: Literal["select"] = Field(default="select", alias="$formkit")
237 options: OptionsType = Field(None)
240class AutocompleteNode(FormKitSchemaProps):
241 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
242 formkit: Literal["autocomplete"] = Field(default="autocomplete", alias="$formkit")
243 options: OptionsType = Field(None)
246class EmailNode(FormKitSchemaProps):
247 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
248 formkit: Literal["email"] = Field(default="email", alias="$formkit")
251class TelNode(FormKitSchemaProps):
252 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
253 formkit: Literal["tel"] = Field(default="tel", alias="$formkit")
256class DropDownNode(FormKitSchemaProps):
257 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
258 formkit: Literal["dropdown"] = Field(default="dropdown", alias="$formkit")
259 options: OptionsType = Field(None)
260 empty_message: str | None = Field(None, alias="empty-message")
261 select_icon: str | None = Field(None, alias="selectIcon")
262 placeholder: str | None
265class RepeaterNode(FormKitSchemaProps):
266 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
267 formkit: Literal["repeater"] = Field(default="repeater", alias="$formkit")
268 name: str | None = None
269 upControl: bool | None = Field(default=True, description="Show up control")
270 downControl: bool | None = Field(default=True, description="Show down control")
271 addLabel: str | None = Field(default="Add another", description="Label for the add button")
272 min: int | None = Field(None, description="Minimum number of items")
273 max: int | None = Field(None, description="Maximum number of items")
274 validationRules: str | None = Field(None, description="Custom validation rules")
275 itemClass: str | None = Field(None, description="Class for each item")
276 itemsClass: str | None = Field(None, description="Class for the items wrapper")
279class GroupNode(FormKitSchemaProps):
280 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
281 formkit: Literal["group"] = Field(default="group", alias="$formkit")
282 text: str | None
285# This is useful for "isinstance" checks
286# which do not work with "Annotated" below
287FormKitType = (
288 TextNode
289 | TextAreaNode
290 | CheckBoxNode
291 | PasswordNode
292 | SelectNode
293 | AutocompleteNode
294 | EmailNode
295 | NumberNode
296 | RadioNode
297 | GroupNode
298 | DateNode
299 | DatePickerNode
300 | DropDownNode
301 | RepeaterNode
302 | TelNode
303 | CurrencyNode
304 | HiddenNode
305 | UuidNode
306)
308FormKitSchemaFormKit = Annotated[
309 Union[
310 TextNode,
311 TextAreaNode,
312 CheckBoxNode,
313 PasswordNode,
314 SelectNode,
315 AutocompleteNode,
316 EmailNode,
317 NumberNode,
318 RadioNode,
319 GroupNode,
320 DateNode,
321 DatePickerNode,
322 DropDownNode,
323 RepeaterNode,
324 TelNode,
325 CurrencyNode,
326 HiddenNode,
327 UuidNode,
328 ],
329 Field(discriminator="formkit"),
330]
333class FormKitSchemaDOMNode(FormKitSchemaProps):
334 """
335 HTML elements are defined using the $el property.
336 You can use $el to render any HTML element.
337 Attributes can be added with the attrs property,
338 and content is assigned with the children property
339 """
341 node_type: Literal["element"] = Field(default="element", exclude=True)
342 el: str = Field(alias="$el")
343 attrs: FormKitSchemaAttributes | None
345 class Config:
346 allow_population_by_field_name = True
349class FormKitSchemaComponent(FormKitSchemaProps):
350 """
351 Components can be defined with the $cmp property
352 The $cmp property should be a string that references
353 a globally defined component or a component passed
354 into FormKitSchema with the library prop.
355 """
357 node_type: Literal["component"] = Field(default="component", exclude=True)
359 cmp: str = Field(
360 ...,
361 alias="$cmp",
362 description="The $cmp property should be a string that references a globally defined component or a component passed into FormKitSchema with the library prop.", # noqa: E501
363 )
364 props: dict[str, str | Any] | None
366 class Config:
367 allow_population_by_field_name = True
370# This necessary to properly "populate" some more complicated models
371# Forward reference updates removed to avoid Pydantic compatibility issues
372# FormKitSchemaAttributesCondition.update_forward_refs()
373# FormKitAttributeValue.update_forward_refs()
374# # FormKitSchemaDOMNode.update_forward_refs()
375# FormKitSchemaCondition.update_forward_refs()
376# FormKitSchemaComponent.update_forward_refs()
379class FormKitTagParser(HTMLParser):
380 """
381 Reverse an HTML example to schema
382 This is for lazy copy-pasting from the formkit website :)
383 """
385 def __init__(self, html_content, *args, **kwargs):
386 super().__init__(*args, **kwargs)
387 self.data: str | None = None
389 self.current_tag: FormKitSchemaFormKit | None = None
390 self.tags: list[FormKitSchemaFormKit] = []
391 self.parents: list[FormKitSchemaFormKit] = []
392 self.feed(html_content)
394 def handle_starttag(self, tag, attrs):
395 """
396 Read anything that's a "formtag" type
397 """
398 if tag != "formkit":
399 return
400 props = dict(attrs)
401 props["formkit"] = props.pop("type")
403 tag = FormKitSchemaFormKit(**props)
404 self.current_tag = tag
406 if self.parents:
407 self.parents[-1].children.append(tag)
408 else:
409 self.tags.append(tag)
410 self.parents.append(tag)
412 def handle_endtag(self, tag: str) -> None:
413 if tag != "formkit":
414 return
415 if self.parents:
416 self.parents.pop()
418 def handle_data(self, data):
419 if self.current_tag and data.strip():
420 self.current_tag.children.append(data.strip())
421 # Ensure that children is included even when "exclude_unset" is True
422 # since we populated this after the initial tag build
423 self.current_tag.__fields_set__.add("children")
426# FormKitSchemaDOMNode.update_forward_refs()
428Model = TypeVar("Model", bound="BaseModel")
429StrBytes = str | bytes
431Node: TypeAlias = Annotated[
432 Union[
433 FormKitSchemaFormKit,
434 FormKitSchemaDOMNode,
435 FormKitSchemaComponent,
436 FormKitSchemaCondition,
437 ],
438 Field(discriminator="node_type"),
439]
441NODE_TYPE = Literal["condition", "formkit", "element", "component"]
442FORMKIT_TYPE = Literal[
443 "text",
444 "textarea",
445 "tel",
446 "currency",
447 "select",
448 "checkbox",
449 "number",
450 "group",
451 "list",
452 "password",
453 "button",
454 "radio",
455 "form",
456 "date",
457 "datepicker",
458 "dropdown",
459 "repeater",
460 "autocomplete",
461 "email",
462 "uuid",
463 "hidden",
464]
467class Discriminators(TypedDict, total=False):
468 node_type: NODE_TYPE
469 formkit: FORMKIT_TYPE
472def get_node_type(obj: str | dict) -> Discriminators:
473 """
474 Pydantic requires nodes to be "differentiated" by a field value
475 when used in a Union type situation.
476 This function should return the 'node_type' values and if present 'Formkit' value
477 which corresponds to the object being inspected.
478 """
479 if isinstance(obj, str):
480 return {"node_type": "element"}
482 if "__root__" in obj:
483 return get_node_type(obj["__root__"])
485 if isinstance(obj, dict) and len(obj.keys()) == 0:
486 return {"node_type": "element"}
488 for key, return_value in (
489 ("$el", "element"),
490 ("$formkit", "formkit"),
491 ("$cmp", "component"),
492 ):
493 if key in obj:
494 return {"node_type": return_value} # type: ignore
495 raise KeyError(f"Could not determine node type for {obj}")
498NodeTypes = FormKitType | FormKitSchemaDOMNode | FormKitSchemaComponent | FormKitSchemaCondition
501class FormKitNode(BaseModel):
502 __root__: str | Node
504 @classmethod
505 def parse_obj(cls: Type["Model"], obj: str | dict, recursive: bool = True) -> "Model": # noqa: C901
506 """
507 This classmethod differentiates between the different "Node" types
508 when deserializing
509 """
511 def get_additional_props(object_in: dict[str, Any], exclude: set[str] = set()):
512 """
513 Parse the object or database return (dict)
514 to break out fields we handle in JSON
516 A FormKit node can have 'arbitrary' additional properties
517 For instance classes to apply to child nodes
518 here we can't realistically cover every scenario so
519 fall back to JSON storage for thes
521 However: if we're coming from the database we already store these in a separate field
522 """
523 # Things which are not "other attributes"
524 set_handled_keys = {
525 "$formkit",
526 "$el",
527 "if",
528 "for",
529 "then",
530 "else",
531 "children",
532 "node_type",
533 "formkit",
534 "id",
535 }
536 # Merge "additional props" from the input object
537 # with any "unknown" params we received
538 # obj is a dict here because of the earlier check
539 assert isinstance(obj, dict)
540 props: dict[str, Any] = object_in.get("additional_props", {})
541 props.update({k: obj[k] for k in object_in.keys() - exclude - set_handled_keys})
542 return props
544 def get_children(object_in: dict):
545 if children_in := object_in.get("children", None):
546 if isinstance(children_in, str):
547 children_in = [children_in]
549 children_out = []
550 for n in children_in:
551 if isinstance(n, str):
552 children_out.append(n)
553 else:
554 try:
555 children_out.append(cls.parse_obj(n).__root__) # type: ignore
556 except Exception as E:
557 warnings.warn(f"{E}")
558 return children_out
559 else:
560 return None
562 if isinstance(obj, str):
563 return cls(__root__=obj)
565 # There's a discriminator step which needs assisance: `node_type`
566 # must be set on the input object
567 try:
568 node_type = get_node_type(obj)
569 except Exception as E:
570 raise KeyError(f"Node type couln't be determined: {obj}") from E
572 try:
573 parsed = super().parse_obj({**obj, "node_type": node_type["node_type"]})
574 node: NodeTypes = parsed.__root__ # type: ignore
575 except KeyError as E:
576 raise KeyError(f"Unable to parse content {obj} to a {cls}") from E
577 if additional_props := get_additional_props(obj, exclude=set(node.__fields__)):
578 if hasattr(node, "additional_props"):
579 node.additional_props = additional_props
580 # Recursively parse 'child' nodes back to Pydantic models for 'children'
581 if recursive:
582 if hasattr(node, "children"):
583 node.children = get_children(obj)
584 else:
585 if hasattr(node, "children"):
586 node.children = None
587 return parsed
590class FormKitSchema(BaseModel):
591 __root__: list[Node]
593 @classmethod
594 def parse_obj(cls: Type["Model"], obj: Any) -> "Model":
595 """
596 Parse a set of FormKit nodes or a single 'GroupNode' to
597 a 'schema'
598 """
599 # If we're parsing a single node, wrap it in a list
600 if isinstance(obj, dict):
601 return cls.parse_obj([obj])
602 try:
603 return cls(__root__=[FormKitNode.parse_obj(_).__root__ for _ in obj])
604 except TypeError:
605 raise
608# FormKitSchema.update_forward_refs()
609# FormKitSchemaCondition.update_forward_refs()
610# PasswordNode.update_forward_refs()
612FormKitSchemaDefinition = Node | list[Node] | FormKitSchemaCondition