Coverage for formkit_ninja/formkit_schema.py: 59%

1 from __future__ import annotations

2

3 import logging

4 import warnings

5 from html.parser import HTMLParser

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

7

8# Configure Pydantic to avoid forward reference issues

9 import pydantic

10 from pydantic import BaseModel, Field

11

12 pydantic.BaseModel.Config.arbitrary_types_allowed = True

13

14"""

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

16 to Pydantic models.

17"""

18

19

20 logger = logging.getLogger(__name__)

21

22 HtmlAttrs = 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

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

30

31

32 class 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

39 class FormKitSchemaMeta(BaseModel):

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

41

42

43 class FormKitTypeDefinition(BaseModel): ...

44

45

46 class FormKitContextShape(BaseModel):

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

48 value: Any

49 _value: Any

50

51

52 class FormKitListValue(BaseModel):

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

54

55

56 class 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

65 class 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

74 class FormKitAttributeValue(BaseModel):

75 """

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

77 """

78

79 __root__: Any

80

81

82 class FormKitSchemaAttributes(BaseModel):

83 __root__: dict[str, Any]

84

85

86 class 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

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

145

146

147 class 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

154 class 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

160 class DateNode(FormKitSchemaProps):

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

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

163

164

165 class CurrencyNode(FormKitSchemaProps):

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

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

168

169

170 class UuidNode(FormKitSchemaProps):

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

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

173

174

175 class 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

187 class CheckBoxNode(FormKitSchemaProps):

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

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

190

191

192 class 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

201 class 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

207 class HiddenNode(FormKitSchemaProps):

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

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

210

211

212 class 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

219 class 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

225 class 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

231 class EmailNode(FormKitSchemaProps):

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

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

234

235

236 class TelNode(FormKitSchemaProps):

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

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

239

240

241 class 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

250 class 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

264 class 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

272 FormKitType = (

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

293 FormKitSchemaFormKit = 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

318 class 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

334 class 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

364 class 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

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

414 StrBytes = str | bytes

415

416 Node: TypeAlias = Annotated[

417 Union[

418 FormKitSchemaFormKit,

419 FormKitSchemaDOMNode,

420 FormKitSchemaComponent,

421 FormKitSchemaCondition,

422 ],

423 Field(discriminator="node_type"),

424]

425

426 NODE_TYPE = Literal["condition", "formkit", "element", "component"]

427 FORMKIT_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

451 class Discriminators(TypedDict, total=False):

452 node_type: NODE_TYPE

453 formkit: FORMKIT_TYPE

454

455

456 def 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

482 NodeTypes = FormKitType | FormKitSchemaDOMNode | FormKitSchemaComponent | FormKitSchemaCondition

483

484

485 class 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

574 class 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

596 FormKitSchemaDefinition = Node | list[Node] | FormKitSchemaCondition