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

1from __future__ import annotations 

2 

3import logging 

4import warnings 

5from html.parser import HTMLParser 

6from typing import Annotated, Any, Literal, Type, TypeAlias, TypedDict, TypeVar, Union 

7 

8# Configure Pydantic to avoid forward reference issues 

9import pydantic 

10from pydantic import BaseModel, Field 

11 

12pydantic.BaseModel.Config.arbitrary_types_allowed = True 

13 

14""" 

15This is a port of selected parts of the FormKit schema 

16to Pydantic models. 

17""" 

18 

19 

20logger = logging.getLogger(__name__) 

21 

22HtmlAttrs = dict[str, str | dict[str, str]] 

23 

24 

25# Node is defined below as a TypeAlias 

26 

27# Radio, Select, Autocomplete and Dropdown nodes have 

28# these options 

29OptionsType = str | list[dict[str, Any]] | list[str] | dict[str, str] | None 

30 

31 

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") 

37 

38 

39class FormKitSchemaMeta(BaseModel): 

40 __root__: dict[str, str | float | int | bool | None] 

41 

42 

43class FormKitTypeDefinition(BaseModel): ... 

44 

45 

46class FormKitContextShape(BaseModel): 

47 type: Literal["input", "list", "group"] 

48 value: Any 

49 _value: Any 

50 

51 

52class FormKitListValue(BaseModel): 

53 __root__: str | list[str] | list[dict[str, str]] 

54 

55 

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 """ 

61 

62 __root__: tuple[str, float | int | str, list[FormKitListValue]] 

63 

64 

65class FormKitSchemaAttributesCondition(BaseModel): 

66 if_: str = Field(alias="if") 

67 then_: FormKitAttributeValue = Field(alias="then") 

68 else_: FormKitAttributeValue | None = Field(alias="else") 

69 

70 class Config: 

71 allow_population_by_field_name = True 

72 

73 

74class FormKitAttributeValue(BaseModel): 

75 """ 

76 The possible value types of attributes (in the schema) 

77 """ 

78 

79 __root__: Any 

80 

81 

82class FormKitSchemaAttributes(BaseModel): 

83 __root__: dict[str, Any] 

84 

85 

86class FormKitSchemaProps(BaseModel): 

87 """ 

88 Properties available in all schema nodes. 

89 """ 

90 

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 

102 

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) 

122 

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 

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) 

134 

135 class Config: 

136 allow_population_by_field_name = True 

137 

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) 

145 

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"] 

152 

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 != ""} 

156 

157 

158# We defined this after the model above as it's a circular reference 

159ChildNodeType = str | list[FormKitSchemaProps | str] | FormKitSchemaCondition | None 

160 

161 

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") 

167 

168 

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 

173 

174 

175class DateNode(FormKitSchemaProps): 

176 node_type: Literal["formkit"] = Field(default="formkit", exclude=True) 

177 formkit: Literal["date"] = Field(default="date", alias="$formkit") 

178 

179 

180class CurrencyNode(FormKitSchemaProps): 

181 node_type: Literal["formkit"] = Field(default="formkit", exclude=True) 

182 formkit: Literal["currency"] = Field(default="currency", alias="$formkit") 

183 

184 

185class UuidNode(FormKitSchemaProps): 

186 node_type: Literal["formkit"] = Field(default="formkit", exclude=True) 

187 formkit: Literal["uuid"] = Field(default="uuid", alias="$formkit") 

188 

189 

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") 

200 

201 

202class CheckBoxNode(FormKitSchemaProps): 

203 node_type: Literal["formkit"] = Field(default="formkit", exclude=True) 

204 formkit: Literal["checkbox"] = Field(default="checkbox", alias="$formkit") 

205 

206 

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 

214 

215 

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 

220 

221 

222class HiddenNode(FormKitSchemaProps): 

223 node_type: Literal["formkit"] = Field(default="formkit", exclude=True) 

224 formkit: Literal["hidden"] = Field(default="hidden", alias="$formkit") 

225 

226 

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) 

232 

233 

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) 

238 

239 

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) 

244 

245 

246class EmailNode(FormKitSchemaProps): 

247 node_type: Literal["formkit"] = Field(default="formkit", exclude=True) 

248 formkit: Literal["email"] = Field(default="email", alias="$formkit") 

249 

250 

251class TelNode(FormKitSchemaProps): 

252 node_type: Literal["formkit"] = Field(default="formkit", exclude=True) 

253 formkit: Literal["tel"] = Field(default="tel", alias="$formkit") 

254 

255 

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 

263 

264 

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") 

277 

278 

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 

283 

284 

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) 

307 

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] 

331 

332 

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 """ 

340 

341 node_type: Literal["element"] = Field(default="element", exclude=True) 

342 el: str = Field(alias="$el") 

343 attrs: FormKitSchemaAttributes | None 

344 

345 class Config: 

346 allow_population_by_field_name = True 

347 

348 

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 """ 

356 

357 node_type: Literal["component"] = Field(default="component", exclude=True) 

358 

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 

365 

366 class Config: 

367 allow_population_by_field_name = True 

368 

369 

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() 

377 

378 

379class FormKitTagParser(HTMLParser): 

380 """ 

381 Reverse an HTML example to schema 

382 This is for lazy copy-pasting from the formkit website :) 

383 """ 

384 

385 def __init__(self, html_content, *args, **kwargs): 

386 super().__init__(*args, **kwargs) 

387 self.data: str | None = None 

388 

389 self.current_tag: FormKitSchemaFormKit | None = None 

390 self.tags: list[FormKitSchemaFormKit] = [] 

391 self.parents: list[FormKitSchemaFormKit] = [] 

392 self.feed(html_content) 

393 

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") 

402 

403 tag = FormKitSchemaFormKit(**props) 

404 self.current_tag = tag 

405 

406 if self.parents: 

407 self.parents[-1].children.append(tag) 

408 else: 

409 self.tags.append(tag) 

410 self.parents.append(tag) 

411 

412 def handle_endtag(self, tag: str) -> None: 

413 if tag != "formkit": 

414 return 

415 if self.parents: 

416 self.parents.pop() 

417 

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") 

424 

425 

426# FormKitSchemaDOMNode.update_forward_refs() 

427 

428Model = TypeVar("Model", bound="BaseModel") 

429StrBytes = str | bytes 

430 

431Node: TypeAlias = Annotated[ 

432 Union[ 

433 FormKitSchemaFormKit, 

434 FormKitSchemaDOMNode, 

435 FormKitSchemaComponent, 

436 FormKitSchemaCondition, 

437 ], 

438 Field(discriminator="node_type"), 

439] 

440 

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] 

465 

466 

467class Discriminators(TypedDict, total=False): 

468 node_type: NODE_TYPE 

469 formkit: FORMKIT_TYPE 

470 

471 

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"} 

481 

482 if "__root__" in obj: 

483 return get_node_type(obj["__root__"]) 

484 

485 if isinstance(obj, dict) and len(obj.keys()) == 0: 

486 return {"node_type": "element"} 

487 

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}") 

496 

497 

498NodeTypes = FormKitType | FormKitSchemaDOMNode | FormKitSchemaComponent | FormKitSchemaCondition 

499 

500 

501class FormKitNode(BaseModel): 

502 __root__: str | Node 

503 

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 """ 

510 

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 

515 

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 

520 

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 

543 

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] 

548 

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 

561 

562 if isinstance(obj, str): 

563 return cls(__root__=obj) 

564 

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 

571 

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 

588 

589 

590class FormKitSchema(BaseModel): 

591 __root__: list[Node] 

592 

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 

606 

607 

608# FormKitSchema.update_forward_refs() 

609# FormKitSchemaCondition.update_forward_refs() 

610# PasswordNode.update_forward_refs() 

611 

612FormKitSchemaDefinition = Node | list[Node] | FormKitSchemaCondition