Coverage for formkit_ninja / formkit_schema.py: 61.76%

288 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-03 09:21 +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 list_filter: bool | None = Field(None, exclude=True) 

131 

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) 

135 

136 class Config: 

137 allow_population_by_field_name = True 

138 

139 def dict(self, *args, **kwargs): 

140 # Set some sensible defaults for "to_dict" 

141 if "by_alias" not in kwargs: 141 ↛ 143line 141 didn't jump to line 143 because the condition on line 141 was always true

142 kwargs["by_alias"] = True 

143 if "exclude_none" not in kwargs: 143 ↛ 145line 143 didn't jump to line 145 because the condition on line 143 was always true

144 kwargs["exclude_none"] = True 

145 _ = super().dict(*args, **kwargs) 

146 

147 # Merge additional_props if they exist 

148 if "additional_props" in _: 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 additional = _["additional_props"] 

150 if additional: 

151 _.update(additional) 

152 del _["additional_props"] 

153 

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

157 

158 

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

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

161 

162 

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

168 

169 

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 

174 

175 

176class DateNode(FormKitSchemaProps): 

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

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

179 

180 

181class CurrencyNode(FormKitSchemaProps): 

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

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

184 

185 

186class UuidNode(FormKitSchemaProps): 

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

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

189 

190 

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

201 

202 

203class CheckBoxNode(FormKitSchemaProps): 

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

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

206 

207 

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 

215 

216 

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 

221 

222 

223class HiddenNode(FormKitSchemaProps): 

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

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

226 

227 

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) 

233 

234 

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) 

239 

240 

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) 

245 

246 

247class EmailNode(FormKitSchemaProps): 

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

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

250 

251 

252class TelNode(FormKitSchemaProps): 

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

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

255 

256 

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 

264 

265 

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

278 

279 

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 

284 

285 

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) 

308 

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] 

332 

333 

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

341 

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

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

344 attrs: FormKitSchemaAttributes | None 

345 

346 class Config: 

347 allow_population_by_field_name = True 

348 

349 

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

357 

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

359 

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 

366 

367 class Config: 

368 allow_population_by_field_name = True 

369 

370 

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

378 

379 

380class FormKitTagParser(HTMLParser): 

381 """ 

382 Reverse an HTML example to schema 

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

384 """ 

385 

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

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

388 self.data: str | None = None 

389 

390 self.current_tag: FormKitSchemaFormKit | None = None 

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

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

393 self.feed(html_content) 

394 

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

403 

404 tag = FormKitSchemaFormKit(**props) 

405 self.current_tag = tag 

406 

407 if self.parents: 

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

409 else: 

410 self.tags.append(tag) 

411 self.parents.append(tag) 

412 

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

414 if tag != "formkit": 

415 return 

416 if self.parents: 

417 self.parents.pop() 

418 

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

425 

426 

427# FormKitSchemaDOMNode.update_forward_refs() 

428 

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

430StrBytes = str | bytes 

431 

432Node: TypeAlias = Annotated[ 

433 Union[ 

434 FormKitSchemaFormKit, 

435 FormKitSchemaDOMNode, 

436 FormKitSchemaComponent, 

437 FormKitSchemaCondition, 

438 ], 

439 Field(discriminator="node_type"), 

440] 

441 

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] 

466 

467 

468class Discriminators(TypedDict, total=False): 

469 node_type: NODE_TYPE 

470 formkit: FORMKIT_TYPE 

471 

472 

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

482 

483 if "__root__" in obj: 

484 return get_node_type(obj["__root__"]) 

485 

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

487 return {"node_type": "element"} 

488 

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

497 

498 

499NodeTypes = FormKitType | FormKitSchemaDOMNode | FormKitSchemaComponent | FormKitSchemaCondition 

500 

501 

502class FormKitNode(BaseModel): 

503 __root__: str | Node 

504 

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

511 

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 

516 

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 

521 

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 

544 

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] 

549 

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 

562 

563 if isinstance(obj, str): 

564 return cls(__root__=obj) 

565 

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 

572 

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 

589 

590 

591class FormKitSchema(BaseModel): 

592 __root__: list[Node] 

593 

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 

607 

608 

609# FormKitSchema.update_forward_refs() 

610# FormKitSchemaCondition.update_forward_refs() 

611# PasswordNode.update_forward_refs() 

612 

613FormKitSchemaDefinition = Node | list[Node] | FormKitSchemaCondition