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