Coverage for formkit_schema.py: 59%

279 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-22 07:15 +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 # 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) 

126 

127 class Config: 

128 allow_population_by_field_name = True 

129 

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 _ 

141 

142 

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

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

145 

146 

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

152 

153 

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 

158 

159 

160class DateNode(FormKitSchemaProps): 

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

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

163 

164 

165class CurrencyNode(FormKitSchemaProps): 

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

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

168 

169 

170class UuidNode(FormKitSchemaProps): 

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

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

173 

174 

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

185 

186 

187class CheckBoxNode(FormKitSchemaProps): 

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

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

190 

191 

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 

199 

200 

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 

205 

206 

207class HiddenNode(FormKitSchemaProps): 

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

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

210 

211 

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) 

217 

218 

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) 

223 

224 

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) 

229 

230 

231class EmailNode(FormKitSchemaProps): 

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

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

234 

235 

236class TelNode(FormKitSchemaProps): 

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

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

239 

240 

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 

248 

249 

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

262 

263 

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 

268 

269 

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) 

292 

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] 

316 

317 

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

325 

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

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

328 attrs: FormKitSchemaAttributes | None 

329 

330 class Config: 

331 allow_population_by_field_name = True 

332 

333 

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

341 

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

343 

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 

350 

351 class Config: 

352 allow_population_by_field_name = True 

353 

354 

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

362 

363 

364class FormKitTagParser(HTMLParser): 

365 """ 

366 Reverse an HTML example to schema 

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

368 """ 

369 

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

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

372 self.data: str | None = None 

373 

374 self.current_tag: FormKitSchemaFormKit | None = None 

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

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

377 self.feed(html_content) 

378 

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

387 

388 tag = FormKitSchemaFormKit(**props) 

389 self.current_tag = tag 

390 

391 if self.parents: 

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

393 else: 

394 self.tags.append(tag) 

395 self.parents.append(tag) 

396 

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

398 if tag != "formkit": 

399 return 

400 if self.parents: 

401 self.parents.pop() 

402 

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

409 

410 

411# FormKitSchemaDOMNode.update_forward_refs() 

412 

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

414StrBytes = str | bytes 

415 

416Node: TypeAlias = Annotated[ 

417 Union[ 

418 FormKitSchemaFormKit, 

419 FormKitSchemaDOMNode, 

420 FormKitSchemaComponent, 

421 FormKitSchemaCondition, 

422 ], 

423 Field(discriminator="node_type"), 

424] 

425 

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] 

449 

450 

451class Discriminators(TypedDict, total=False): 

452 node_type: NODE_TYPE 

453 formkit: FORMKIT_TYPE 

454 

455 

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

465 

466 if "__root__" in obj: 

467 return get_node_type(obj["__root__"]) 

468 

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

470 return {"node_type": "element"} 

471 

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

480 

481 

482NodeTypes = FormKitType | FormKitSchemaDOMNode | FormKitSchemaComponent | FormKitSchemaCondition 

483 

484 

485class FormKitNode(BaseModel): 

486 __root__: str | Node 

487 

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

494 

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 

499 

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 

504 

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 

527 

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] 

532 

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 

545 

546 if isinstance(obj, str): 

547 return cls(__root__=obj) 

548 

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 

555 

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 

572 

573 

574class FormKitSchema(BaseModel): 

575 __root__: list[Node] 

576 

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 

590 

591 

592# FormKitSchema.update_forward_refs() 

593# FormKitSchemaCondition.update_forward_refs() 

594# PasswordNode.update_forward_refs() 

595 

596FormKitSchemaDefinition = Node | list[Node] | FormKitSchemaCondition