Coverage for formkit_ninja / parser / converters.py: 49.57%

180 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-03 09:21 +0000

1""" 

2Type converter system for FormKit node to Pydantic type conversion. 

3 

4This module provides: 

5- TypeConverter Protocol: Interface for type converters 

6- TypeConverterRegistry: Registry for managing and retrieving converters 

7- Default converter implementations: TextConverter, NumberConverter, DateConverter, BooleanConverter 

8""" 

9 

10from __future__ import annotations 

11 

12from typing import Protocol 

13 

14from formkit_ninja.formkit_schema import ( 

15 DateNode, 

16 DatePickerNode, 

17 FormKitType, 

18 NumberNode, 

19 TelNode, 

20) 

21 

22 

23class TypeConverter(Protocol): 

24 """ 

25 Protocol for type converters that convert FormKit nodes to Pydantic types. 

26 

27 Converters must implement: 

28 - can_convert(): Check if this converter can handle a given node 

29 - to_pydantic_type(): Convert the node to a Pydantic type string 

30 

31 Optional methods for enhanced matching: 

32 - can_convert_by_name(): Check if converter matches by node name 

33 - can_convert_by_options(): Check if converter matches by node options 

34 """ 

35 

36 def can_convert(self, node: FormKitType) -> bool: 

37 """ 

38 Check if this converter can convert the given node. 

39 

40 Args: 

41 node: The FormKit node to check 

42 

43 Returns: 

44 True if this converter can handle the node, False otherwise 

45 """ 

46 ... 

47 

48 def to_pydantic_type(self, node: FormKitType) -> str: 

49 """ 

50 Convert the node to a Pydantic type string. 

51 

52 Args: 

53 node: The FormKit node to convert 

54 

55 Returns: 

56 A string representing the Pydantic type (e.g., "str", "int", "bool") 

57 """ 

58 ... 

59 

60 def to_django_type(self, node: FormKitType) -> str: 

61 """Return the Django field type string.""" 

62 ... 

63 

64 def to_django_args(self, node: FormKitType) -> dict[str, str]: 

65 """Return a dict of Django field arguments.""" 

66 ... 

67 

68 @property 

69 def validators(self) -> list[str]: 

70 """Return a list of validator strings.""" 

71 ... 

72 

73 @property 

74 def extra_imports(self) -> list[str]: 

75 """Return a list of extra import statements.""" 

76 ... 

77 

78 

79class BaseConverter: 

80 """ 

81 Optional base class for converters that provides default implementations 

82 for the new Django and metadata methods. 

83 """ 

84 

85 def to_django_type(self, node: FormKitType) -> str: 

86 """Default fallback to TextField.""" 

87 return "TextField" 

88 

89 def to_django_args(self, node: FormKitType) -> dict[str, str]: 

90 """Default fallback to null=True, blank=True.""" 

91 return {"null": "True", "blank": "True"} 

92 

93 @property 

94 def validators(self) -> list[str]: 

95 """Default to no validators.""" 

96 return [] 

97 

98 @property 

99 def extra_imports(self) -> list[str]: 

100 """Default to no extra imports.""" 

101 return [] 

102 

103 

104class TypeConverterRegistry: 

105 """ 

106 Registry for managing type converters with priority-based ordering. 

107 

108 Converters are checked in order of priority (higher priority first). 

109 When multiple converters have the same priority, they are checked 

110 in registration order. 

111 """ 

112 

113 def __init__(self) -> None: 

114 """Initialize an empty registry.""" 

115 self._converters: list[tuple[int, TypeConverter]] = [] 

116 

117 def register(self, converter: TypeConverter, priority: int = 0) -> None: 

118 """ 

119 Register a type converter with optional priority. 

120 

121 Args: 

122 converter: The converter instance to register 

123 priority: Priority level (higher values checked first). Defaults to 0. 

124 """ 

125 self._converters.append((priority, converter)) 

126 # Sort by priority (descending), maintaining registration order for same priority 

127 self._converters.sort(key=lambda x: x[0], reverse=True) 

128 

129 def get_converter(self, node: FormKitType) -> TypeConverter | None: 

130 """ 

131 Get the first converter that can handle the given node. 

132 

133 Converters are checked in priority order (higher priority first). 

134 If multiple converters have the same priority, they are checked 

135 in registration order. 

136 

137 Matching is attempted in this order: 

138 1. can_convert(node) - checks formkit attribute (existing behavior) 

139 2. can_convert_by_name(node.name) - if node has name attribute 

140 3. can_convert_by_options(str(node.options)) - if node has options attribute 

141 

142 Args: 

143 node: The FormKit node to find a converter for 

144 

145 Returns: 

146 The first matching converter, or None if no converter matches 

147 """ 

148 # First, try can_convert (formkit-based matching) 

149 for _, converter in self._converters: 149 ↛ 154line 149 didn't jump to line 154 because the loop on line 149 didn't complete

150 if converter.can_convert(node): 

151 return converter 

152 

153 # If no formkit match and node has name, try name-based matching 

154 if hasattr(node, "name") and node.name is not None: 

155 for _, converter in self._converters: 

156 # Check if converter has can_convert_by_name method 

157 if hasattr(converter, "can_convert_by_name"): 

158 if converter.can_convert_by_name(node.name): 

159 return converter 

160 

161 # If still no match and node has options, try options-based matching 

162 if hasattr(node, "options") and node.options is not None: 

163 options_str = str(node.options) 

164 for _, converter in self._converters: 

165 # Check if converter has can_convert_by_options method 

166 if hasattr(converter, "can_convert_by_options"): 

167 if converter.can_convert_by_options(options_str): 

168 return converter 

169 

170 return None 

171 

172 

173class TextConverter(BaseConverter): 

174 """ 

175 Converter for text-based FormKit nodes. 

176 

177 Handles: text, textarea, email, password, hidden, select, dropdown, radio, autocomplete 

178 Returns: "str" 

179 """ 

180 

181 @property 

182 def validators(self) -> list[str]: 

183 return [] 

184 

185 @property 

186 def extra_imports(self) -> list[str]: 

187 return [] 

188 

189 def to_django_type(self, node: FormKitType) -> str: 

190 return "TextField" 

191 

192 def to_django_args(self, node: FormKitType) -> dict[str, str]: 

193 return {"null": "True", "blank": "True"} 

194 

195 def can_convert(self, node: FormKitType) -> bool: 

196 """ 

197 Check if this converter can convert the given node. 

198 

199 Args: 

200 node: The FormKit node to check 

201 

202 Returns: 

203 True if the node is a text-based node, False otherwise 

204 """ 

205 if not hasattr(node, "formkit"): 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 return False 

207 

208 text_types = { 

209 "text", 

210 "textarea", 

211 "email", 

212 "password", 

213 "hidden", 

214 "select", 

215 "dropdown", 

216 "radio", 

217 "autocomplete", 

218 } 

219 return node.formkit in text_types 

220 

221 def to_pydantic_type(self, node: FormKitType) -> str: 

222 """ 

223 Convert the node to a Pydantic type string. 

224 

225 Args: 

226 node: The FormKit node to convert 

227 

228 Returns: 

229 "str" for all text-based nodes 

230 """ 

231 return "str" 

232 

233 

234class NumberConverter(BaseConverter): 

235 """ 

236 Converter for number-based FormKit nodes. 

237 

238 Handles: number, tel 

239 Returns: "int" or "float" depending on step attribute 

240 """ 

241 

242 @property 

243 def validators(self) -> list[str]: 

244 return [] 

245 

246 @property 

247 def extra_imports(self) -> list[str]: 

248 return [] 

249 

250 def to_django_type(self, node: FormKitType) -> str: 

251 if self.to_pydantic_type(node) == "float": 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 return "FloatField" 

253 return "IntegerField" 

254 

255 def to_django_args(self, node: FormKitType) -> dict[str, str]: 

256 return {"null": "True", "blank": "True"} 

257 

258 def can_convert(self, node: FormKitType) -> bool: 

259 """ 

260 Check if this converter can convert the given node. 

261 

262 Args: 

263 node: The FormKit node to check 

264 

265 Returns: 

266 True if the node is a number-based node, False otherwise 

267 """ 

268 if not hasattr(node, "formkit"): 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 return False 

270 

271 number_types = {"number", "tel"} 

272 return node.formkit in number_types 

273 

274 def to_pydantic_type(self, node: FormKitType) -> str: 

275 """ 

276 Convert the node to a Pydantic type string. 

277 

278 Args: 

279 node: The FormKit node to convert 

280 

281 Returns: 

282 "int" if step is None or not set, "float" if step is set 

283 """ 

284 # TelNode always returns int 

285 if isinstance(node, TelNode): 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 return "int" 

287 

288 # NumberNode: check step attribute 

289 if isinstance(node, NumberNode): 289 ↛ 295line 289 didn't jump to line 295 because the condition on line 289 was always true

290 if node.step is not None: 290 ↛ 291line 290 didn't jump to line 291 because the condition on line 290 was never true

291 return "float" 

292 return "int" 

293 

294 # Fallback (shouldn't happen if can_convert is correct) 

295 return "int" 

296 

297 

298class DateConverter(BaseConverter): 

299 """ 

300 Converter for date-based FormKit nodes. 

301 

302 Handles: datepicker, date 

303 Returns: "date" for both datepicker and date nodes (generates DateField) 

304 """ 

305 

306 @property 

307 def validators(self) -> list[str]: 

308 return [] 

309 

310 @property 

311 def extra_imports(self) -> list[str]: 

312 return [] 

313 

314 def to_django_type(self, node: FormKitType) -> str: 

315 return "DateField" 

316 

317 def to_django_args(self, node: FormKitType) -> dict[str, str]: 

318 return {"null": "True", "blank": "True"} 

319 

320 def can_convert(self, node: FormKitType) -> bool: 

321 """ 

322 Check if this converter can convert the given node. 

323 

324 Args: 

325 node: The FormKit node to check 

326 

327 Returns: 

328 True if the node is a date-based node, False otherwise 

329 """ 

330 if not hasattr(node, "formkit"): 

331 return False 

332 

333 date_types = {"datepicker", "date"} 

334 return node.formkit in date_types 

335 

336 def to_pydantic_type(self, node: FormKitType) -> str: 

337 """ 

338 Convert the node to a Pydantic type string. 

339 

340 Args: 

341 node: The FormKit node to convert 

342 

343 Returns: 

344 "date" for both datepicker and date nodes (generates DateField in Django) 

345 """ 

346 # Both datepicker and date return "date" to generate DateField 

347 if isinstance(node, DatePickerNode): 

348 return "date" 

349 if isinstance(node, DateNode): 

350 return "date" 

351 

352 # Fallback based on formkit attribute 

353 if hasattr(node, "formkit"): 

354 if node.formkit == "datepicker": 

355 return "date" 

356 if node.formkit == "date": 

357 return "date" 

358 

359 # Default fallback (shouldn't happen if can_convert is correct) 

360 return "date" 

361 

362 

363class BooleanConverter(BaseConverter): 

364 """ 

365 Converter for boolean FormKit nodes. 

366 

367 Handles: checkbox 

368 Returns: "bool" 

369 """ 

370 

371 @property 

372 def validators(self) -> list[str]: 

373 return [] 

374 

375 @property 

376 def extra_imports(self) -> list[str]: 

377 return [] 

378 

379 def to_django_type(self, node: FormKitType) -> str: 

380 return "BooleanField" 

381 

382 def to_django_args(self, node: FormKitType) -> dict[str, str]: 

383 return {"null": "True", "blank": "True"} 

384 

385 def can_convert(self, node: FormKitType) -> bool: 

386 """ 

387 Check if this converter can convert the given node. 

388 

389 Args: 

390 node: The FormKit node to check 

391 

392 Returns: 

393 True if the node is a checkbox node, False otherwise 

394 """ 

395 if not hasattr(node, "formkit"): 

396 return False 

397 

398 return node.formkit == "checkbox" 

399 

400 def to_pydantic_type(self, node: FormKitType) -> str: 

401 """ 

402 Convert the node to a Pydantic type string. 

403 

404 Args: 

405 node: The FormKit node to convert 

406 

407 Returns: 

408 "bool" for checkbox nodes 

409 """ 

410 return "bool" 

411 

412 

413class UuidConverter(BaseConverter): 

414 """ 

415 Converter for UUID FormKit nodes. 

416 

417 Handles: uuid 

418 Returns: "UUID" 

419 """ 

420 

421 @property 

422 def validators(self) -> list[str]: 

423 return [] 

424 

425 @property 

426 def extra_imports(self) -> list[str]: 

427 return [] 

428 

429 def to_django_type(self, node: FormKitType) -> str: 

430 return "UUIDField" 

431 

432 def to_django_args(self, node: FormKitType) -> dict[str, str]: 

433 return {"editable": "False", "null": "True", "blank": "True"} 

434 

435 def can_convert(self, node: FormKitType) -> bool: 

436 """ 

437 Check if this converter can convert the given node. 

438 

439 Args: 

440 node: The FormKit node to check 

441 

442 Returns: 

443 True if the node is a uuid node, False otherwise 

444 """ 

445 if not hasattr(node, "formkit"): 

446 return False 

447 

448 return node.formkit == "uuid" 

449 

450 def to_pydantic_type(self, node: FormKitType) -> str: 

451 """ 

452 Convert the node to a Pydantic type string. 

453 

454 Args: 

455 node: The FormKit node to convert 

456 

457 Returns: 

458 "UUID" for uuid nodes 

459 """ 

460 return "UUID" 

461 

462 

463class CurrencyConverter(BaseConverter): 

464 """ 

465 Converter for currency FormKit nodes. 

466 

467 Handles: currency 

468 Returns: "Decimal" 

469 """ 

470 

471 @property 

472 def validators(self) -> list[str]: 

473 return [] 

474 

475 @property 

476 def extra_imports(self) -> list[str]: 

477 return ["from decimal import Decimal"] 

478 

479 def to_django_type(self, node: FormKitType) -> str: 

480 return "DecimalField" 

481 

482 def to_django_args(self, node: FormKitType) -> dict[str, str]: 

483 return { 

484 "max_digits": "20", 

485 "decimal_places": "2", 

486 "null": "True", 

487 "blank": "True", 

488 } 

489 

490 def can_convert(self, node: FormKitType) -> bool: 

491 """ 

492 Check if this converter can convert the given node. 

493 

494 Args: 

495 node: The FormKit node to check 

496 

497 Returns: 

498 True if the node is a currency node, False otherwise 

499 """ 

500 if not hasattr(node, "formkit"): 500 ↛ 501line 500 didn't jump to line 501 because the condition on line 500 was never true

501 return False 

502 

503 return node.formkit == "currency" 

504 

505 def to_pydantic_type(self, node: FormKitType) -> str: 

506 """ 

507 Convert the node to a Pydantic type string. 

508 

509 Args: 

510 node: The FormKit node to convert 

511 

512 Returns: 

513 "Decimal" for currency nodes 

514 """ 

515 return "Decimal" 

516 

517 

518# Default registry with all default converters pre-registered 

519# CurrencyConverter registered with higher priority than TextConverter 

520# to ensure currency fields are detected before falling back to text 

521default_registry = TypeConverterRegistry() 

522default_registry.register(TextConverter()) 

523default_registry.register(NumberConverter()) 

524default_registry.register(DateConverter()) 

525default_registry.register(BooleanConverter()) 

526default_registry.register(UuidConverter()) 

527default_registry.register(CurrencyConverter(), priority=10)