Coverage for formkit_ninja / formkit_schema.py: 58.82%
288 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-06 04:12 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-06 04:12 +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)
130 list_filter: bool | None = Field(None, exclude=True)
132 # FormKit allows arbitrary values, we do our best to represent these here
133 # Additional Props can be quite a complicated structure
134 additional_props: None | dict[str, str | dict[str, Any]] = Field(None)
136 class Config:
137 allow_population_by_field_name = True
139 def dict(self, *args, **kwargs):
140 # Set some sensible defaults for "to_dict"
141 if "by_alias" not in kwargs:
142 kwargs["by_alias"] = True
143 if "exclude_none" not in kwargs:
144 kwargs["exclude_none"] = True
145 _ = super().dict(*args, **kwargs)
147 # Merge additional_props if they exist
148 if "additional_props" in _:
149 additional = _["additional_props"]
150 if additional:
151 _.update(additional)
152 del _["additional_props"]
154 # Filter out empty strings
155 # We do this after merging additional_props so they are also cleaned
156 return {k: v for k, v in _.items() if v != ""}
159# We defined this after the model above as it's a circular reference
160ChildNodeType = str | list[FormKitSchemaProps | str] | FormKitSchemaCondition | None
163class TextNode(FormKitSchemaProps):
164 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
165 formkit: Literal["text"] = Field(default="text", alias="$formkit")
166 text: str | None
167 maxLength: int | None = Field(None, description="Maximum length of the text input")
170class TextAreaNode(FormKitSchemaProps):
171 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
172 formkit: Literal["textarea"] = Field(default="textarea", alias="$formkit")
173 text: str | None
176class DateNode(FormKitSchemaProps):
177 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
178 formkit: Literal["date"] = Field(default="date", alias="$formkit")
181class CurrencyNode(FormKitSchemaProps):
182 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
183 formkit: Literal["currency"] = Field(default="currency", alias="$formkit")
186class UuidNode(FormKitSchemaProps):
187 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
188 formkit: Literal["uuid"] = Field(default="uuid", alias="$formkit")
191class DatePickerNode(FormKitSchemaProps):
192 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
193 formkit: Literal["datepicker"] = Field(default="datepicker", alias="$formkit")
194 calendarIcon: str = "calendar"
195 format: str = "DD/MM/YY"
196 nextIcon: str = "angleRight"
197 prevIcon: str = "angleLeft"
198 minDateSource: str | None = Field(None, alias="_minDateSource", description="Field to use as min date")
199 maxDateSource: str | None = Field(None, alias="_maxDateSource", description="Field to use as max date")
200 disabledDays: str | None = Field(None, description="Function to disable days")
203class CheckBoxNode(FormKitSchemaProps):
204 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
205 formkit: Literal["checkbox"] = Field(default="checkbox", alias="$formkit")
208class NumberNode(FormKitSchemaProps):
209 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
210 formkit: Literal["number"] = Field(default="number", alias="$formkit")
211 text: str | None
212 max: int | None = None
213 min: int | str | None = None
214 step: int | str | None = None
217class PasswordNode(FormKitSchemaProps):
218 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
219 formkit: Literal["password"] = Field(default="password", alias="$formkit")
220 name: str | None
223class HiddenNode(FormKitSchemaProps):
224 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
225 formkit: Literal["hidden"] = Field(default="hidden", alias="$formkit")
228class RadioNode(FormKitSchemaProps):
229 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
230 formkit: Literal["radio"] = Field(default="radio", alias="$formkit")
231 name: str | None
232 options: OptionsType = Field(None)
235class SelectNode(FormKitSchemaProps):
236 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
237 formkit: Literal["select"] = Field(default="select", alias="$formkit")
238 options: OptionsType = Field(None)
241class AutocompleteNode(FormKitSchemaProps):
242 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
243 formkit: Literal["autocomplete"] = Field(default="autocomplete", alias="$formkit")
244 options: OptionsType = Field(None)
247class EmailNode(FormKitSchemaProps):
248 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
249 formkit: Literal["email"] = Field(default="email", alias="$formkit")
252class TelNode(FormKitSchemaProps):
253 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
254 formkit: Literal["tel"] = Field(default="tel", alias="$formkit")
257class DropDownNode(FormKitSchemaProps):
258 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
259 formkit: Literal["dropdown"] = Field(default="dropdown", alias="$formkit")
260 options: OptionsType = Field(None)
261 empty_message: str | None = Field(None, alias="empty-message")
262 select_icon: str | None = Field(None, alias="selectIcon")
263 placeholder: str | None
266class RepeaterNode(FormKitSchemaProps):
267 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
268 formkit: Literal["repeater"] = Field(default="repeater", alias="$formkit")
269 name: str | None = None
270 upControl: bool | None = Field(default=True, description="Show up control")
271 downControl: bool | None = Field(default=True, description="Show down control")
272 addLabel: str | None = Field(default="Add another", description="Label for the add button")
273 min: int | None = Field(None, description="Minimum number of items")
274 max: int | None = Field(None, description="Maximum number of items")
275 validationRules: str | None = Field(None, description="Custom validation rules")
276 itemClass: str | None = Field(None, description="Class for each item")
277 itemsClass: str | None = Field(None, description="Class for the items wrapper")
280class GroupNode(FormKitSchemaProps):
281 node_type: Literal["formkit"] = Field(default="formkit", exclude=True)
282 formkit: Literal["group"] = Field(default="group", alias="$formkit")
283 text: str | None
286# This is useful for "isinstance" checks
287# which do not work with "Annotated" below
288FormKitType = (
289 TextNode
290 | TextAreaNode
291 | CheckBoxNode
292 | PasswordNode
293 | SelectNode
294 | AutocompleteNode
295 | EmailNode
296 | NumberNode
297 | RadioNode
298 | GroupNode
299 | DateNode
300 | DatePickerNode
301 | DropDownNode
302 | RepeaterNode
303 | TelNode
304 | CurrencyNode
305 | HiddenNode
306 | UuidNode
307)
309FormKitSchemaFormKit = Annotated[
310 Union[
311 TextNode,
312 TextAreaNode,
313 CheckBoxNode,
314 PasswordNode,
315 SelectNode,
316 AutocompleteNode,
317 EmailNode,
318 NumberNode,
319 RadioNode,
320 GroupNode,
321 DateNode,
322 DatePickerNode,
323 DropDownNode,
324 RepeaterNode,
325 TelNode,
326 CurrencyNode,
327 HiddenNode,
328 UuidNode,
329 ],
330 Field(discriminator="formkit"),
331]
334class FormKitSchemaDOMNode(FormKitSchemaProps):
335 """
336 HTML elements are defined using the $el property.
337 You can use $el to render any HTML element.
338 Attributes can be added with the attrs property,
339 and content is assigned with the children property
340 """
342 node_type: Literal["element"] = Field(default="element", exclude=True)
343 el: str = Field(alias="$el")
344 attrs: FormKitSchemaAttributes | None
346 class Config:
347 allow_population_by_field_name = True
350class FormKitSchemaComponent(FormKitSchemaProps):
351 """
352 Components can be defined with the $cmp property
353 The $cmp property should be a string that references
354 a globally defined component or a component passed
355 into FormKitSchema with the library prop.
356 """
358 node_type: Literal["component"] = Field(default="component", exclude=True)
360 cmp: str = Field(
361 ...,
362 alias="$cmp",
363 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
364 )
365 props: dict[str, str | Any] | None
367 class Config:
368 allow_population_by_field_name = True
371# This necessary to properly "populate" some more complicated models
372# Forward reference updates removed to avoid Pydantic compatibility issues
373# FormKitSchemaAttributesCondition.update_forward_refs()
374# FormKitAttributeValue.update_forward_refs()
375# # FormKitSchemaDOMNode.update_forward_refs()
376# FormKitSchemaCondition.update_forward_refs()
377# FormKitSchemaComponent.update_forward_refs()
380class FormKitTagParser(HTMLParser):
381 """
382 Reverse an HTML example to schema
383 This is for lazy copy-pasting from the formkit website :)
384 """
386 def __init__(self, html_content, *args, **kwargs):
387 super().__init__(*args, **kwargs)
388 self.data: str | None = None
390 self.current_tag: FormKitSchemaFormKit | None = None
391 self.tags: list[FormKitSchemaFormKit] = []
392 self.parents: list[FormKitSchemaFormKit] = []
393 self.feed(html_content)
395 def handle_starttag(self, tag, attrs):
396 """
397 Read anything that's a "formtag" type
398 """
399 if tag != "formkit":
400 return
401 props = dict(attrs)
402 props["formkit"] = props.pop("type")
404 tag = FormKitSchemaFormKit(**props)
405 self.current_tag = tag
407 if self.parents:
408 self.parents[-1].children.append(tag)
409 else:
410 self.tags.append(tag)
411 self.parents.append(tag)
413 def handle_endtag(self, tag: str) -> None:
414 if tag != "formkit":
415 return
416 if self.parents:
417 self.parents.pop()
419 def handle_data(self, data):
420 if self.current_tag and data.strip():
421 self.current_tag.children.append(data.strip())
422 # Ensure that children is included even when "exclude_unset" is True
423 # since we populated this after the initial tag build
424 self.current_tag.__fields_set__.add("children")
427# FormKitSchemaDOMNode.update_forward_refs()
429Model = TypeVar("Model", bound="BaseModel")
430StrBytes = str | bytes
432Node: TypeAlias = Annotated[
433 Union[
434 FormKitSchemaFormKit,
435 FormKitSchemaDOMNode,
436 FormKitSchemaComponent,
437 FormKitSchemaCondition,
438 ],
439 Field(discriminator="node_type"),
440]
442NODE_TYPE = Literal["condition", "formkit", "element", "component"]
443FORMKIT_TYPE = Literal[
444 "text",
445 "textarea",
446 "tel",
447 "currency",
448 "select",
449 "checkbox",
450 "number",
451 "group",
452 "list",
453 "password",
454 "button",
455 "radio",
456 "form",
457 "date",
458 "datepicker",
459 "dropdown",
460 "repeater",
461 "autocomplete",
462 "email",
463 "uuid",
464 "hidden",
465]
468class Discriminators(TypedDict, total=False):
469 node_type: NODE_TYPE
470 formkit: FORMKIT_TYPE
473def get_node_type(obj: str | dict) -> Discriminators:
474 """
475 Pydantic requires nodes to be "differentiated" by a field value
476 when used in a Union type situation.
477 This function should return the 'node_type' values and if present 'Formkit' value
478 which corresponds to the object being inspected.
479 """
480 if isinstance(obj, str):
481 return {"node_type": "element"}
483 if "__root__" in obj:
484 return get_node_type(obj["__root__"])
486 if isinstance(obj, dict) and len(obj.keys()) == 0:
487 return {"node_type": "element"}
489 for key, return_value in (
490 ("$el", "element"),
491 ("$formkit", "formkit"),
492 ("$cmp", "component"),
493 ):
494 if key in obj:
495 return {"node_type": return_value} # type: ignore
496 raise KeyError(f"Could not determine node type for {obj}")
499NodeTypes = FormKitType | FormKitSchemaDOMNode | FormKitSchemaComponent | FormKitSchemaCondition
502class FormKitNode(BaseModel):
503 __root__: str | Node
505 @classmethod
506 def parse_obj(cls: Type["Model"], obj: str | dict, recursive: bool = True) -> "Model": # noqa: C901
507 """
508 This classmethod differentiates between the different "Node" types
509 when deserializing
510 """
512 def get_additional_props(object_in: dict[str, Any], exclude: set[str] = set()):
513 """
514 Parse the object or database return (dict)
515 to break out fields we handle in JSON
517 A FormKit node can have 'arbitrary' additional properties
518 For instance classes to apply to child nodes
519 here we can't realistically cover every scenario so
520 fall back to JSON storage for thes
522 However: if we're coming from the database we already store these in a separate field
523 """
524 # Things which are not "other attributes"
525 set_handled_keys = {
526 "$formkit",
527 "$el",
528 "if",
529 "for",
530 "then",
531 "else",
532 "children",
533 "node_type",
534 "formkit",
535 "id",
536 }
537 # Merge "additional props" from the input object
538 # with any "unknown" params we received
539 # obj is a dict here because of the earlier check
540 assert isinstance(obj, dict)
541 props: dict[str, Any] = object_in.get("additional_props", {})
542 props.update({k: obj[k] for k in object_in.keys() - exclude - set_handled_keys})
543 return props
545 def get_children(object_in: dict):
546 if children_in := object_in.get("children", None):
547 if isinstance(children_in, str):
548 children_in = [children_in]
550 children_out = []
551 for n in children_in:
552 if isinstance(n, str):
553 children_out.append(n)
554 else:
555 try:
556 children_out.append(cls.parse_obj(n).__root__) # type: ignore
557 except Exception as E:
558 warnings.warn(f"{E}")
559 return children_out
560 else:
561 return None
563 if isinstance(obj, str):
564 return cls(__root__=obj)
566 # There's a discriminator step which needs assisance: `node_type`
567 # must be set on the input object
568 try:
569 node_type = get_node_type(obj)
570 except Exception as E:
571 raise KeyError(f"Node type couln't be determined: {obj}") from E
573 try:
574 parsed = super().parse_obj({**obj, "node_type": node_type["node_type"]})
575 node: NodeTypes = parsed.__root__ # type: ignore
576 except KeyError as E:
577 raise KeyError(f"Unable to parse content {obj} to a {cls}") from E
578 if additional_props := get_additional_props(obj, exclude=set(node.__fields__)):
579 if hasattr(node, "additional_props"):
580 node.additional_props = additional_props
581 # Recursively parse 'child' nodes back to Pydantic models for 'children'
582 if recursive:
583 if hasattr(node, "children"):
584 node.children = get_children(obj)
585 else:
586 if hasattr(node, "children"):
587 node.children = None
588 return parsed
591class FormKitSchema(BaseModel):
592 __root__: list[Node]
594 @classmethod
595 def parse_obj(cls: Type["Model"], obj: Any) -> "Model":
596 """
597 Parse a set of FormKit nodes or a single 'GroupNode' to
598 a 'schema'
599 """
600 # If we're parsing a single node, wrap it in a list
601 if isinstance(obj, dict):
602 return cls.parse_obj([obj])
603 try:
604 return cls(__root__=[FormKitNode.parse_obj(_).__root__ for _ in obj])
605 except TypeError:
606 raise
609# FormKitSchema.update_forward_refs()
610# FormKitSchemaCondition.update_forward_refs()
611# PasswordNode.update_forward_refs()
613FormKitSchemaDefinition = Node | list[Node] | FormKitSchemaCondition