Coverage for formkit_ninja / parser / converters.py: 37.83%
180 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-20 04:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-20 04:40 +0000
1"""
2Type converter system for FormKit node to Pydantic type conversion.
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"""
10from __future__ import annotations
12from typing import Protocol
14from formkit_ninja.formkit_schema import (
15 DateNode,
16 DatePickerNode,
17 FormKitType,
18 NumberNode,
19 TelNode,
20)
23class TypeConverter(Protocol):
24 """
25 Protocol for type converters that convert FormKit nodes to Pydantic types.
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
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 """
36 def can_convert(self, node: FormKitType) -> bool:
37 """
38 Check if this converter can convert the given node.
40 Args:
41 node: The FormKit node to check
43 Returns:
44 True if this converter can handle the node, False otherwise
45 """
46 ...
48 def to_pydantic_type(self, node: FormKitType) -> str:
49 """
50 Convert the node to a Pydantic type string.
52 Args:
53 node: The FormKit node to convert
55 Returns:
56 A string representing the Pydantic type (e.g., "str", "int", "bool")
57 """
58 ...
60 def to_django_type(self, node: FormKitType) -> str:
61 """Return the Django field type string."""
62 ...
64 def to_django_args(self, node: FormKitType) -> dict[str, str]:
65 """Return a dict of Django field arguments."""
66 ...
68 @property
69 def validators(self) -> list[str]:
70 """Return a list of validator strings."""
71 ...
73 @property
74 def extra_imports(self) -> list[str]:
75 """Return a list of extra import statements."""
76 ...
79class BaseConverter:
80 """
81 Optional base class for converters that provides default implementations
82 for the new Django and metadata methods.
83 """
85 def to_django_type(self, node: FormKitType) -> str:
86 """Default fallback to TextField."""
87 return "TextField"
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"}
93 @property
94 def validators(self) -> list[str]:
95 """Default to no validators."""
96 return []
98 @property
99 def extra_imports(self) -> list[str]:
100 """Default to no extra imports."""
101 return []
104class TypeConverterRegistry:
105 """
106 Registry for managing type converters with priority-based ordering.
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 """
113 def __init__(self) -> None:
114 """Initialize an empty registry."""
115 self._converters: list[tuple[int, TypeConverter]] = []
117 def register(self, converter: TypeConverter, priority: int = 0) -> None:
118 """
119 Register a type converter with optional priority.
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)
129 def get_converter(self, node: FormKitType) -> TypeConverter | None:
130 """
131 Get the first converter that can handle the given node.
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.
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
142 Args:
143 node: The FormKit node to find a converter for
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:
150 if converter.can_convert(node):
151 return converter
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
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
170 return None
173class TextConverter(BaseConverter):
174 """
175 Converter for text-based FormKit nodes.
177 Handles: text, textarea, email, password, hidden, select, dropdown, radio, autocomplete
178 Returns: "str"
179 """
181 @property
182 def validators(self) -> list[str]:
183 return []
185 @property
186 def extra_imports(self) -> list[str]:
187 return []
189 def to_django_type(self, node: FormKitType) -> str:
190 return "TextField"
192 def to_django_args(self, node: FormKitType) -> dict[str, str]:
193 return {"null": "True", "blank": "True"}
195 def can_convert(self, node: FormKitType) -> bool:
196 """
197 Check if this converter can convert the given node.
199 Args:
200 node: The FormKit node to check
202 Returns:
203 True if the node is a text-based node, False otherwise
204 """
205 if not hasattr(node, "formkit"):
206 return False
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
221 def to_pydantic_type(self, node: FormKitType) -> str:
222 """
223 Convert the node to a Pydantic type string.
225 Args:
226 node: The FormKit node to convert
228 Returns:
229 "str" for all text-based nodes
230 """
231 return "str"
234class NumberConverter(BaseConverter):
235 """
236 Converter for number-based FormKit nodes.
238 Handles: number, tel
239 Returns: "int" or "float" depending on step attribute
240 """
242 @property
243 def validators(self) -> list[str]:
244 return []
246 @property
247 def extra_imports(self) -> list[str]:
248 return []
250 def to_django_type(self, node: FormKitType) -> str:
251 if self.to_pydantic_type(node) == "float":
252 return "FloatField"
253 return "IntegerField"
255 def to_django_args(self, node: FormKitType) -> dict[str, str]:
256 return {"null": "True", "blank": "True"}
258 def can_convert(self, node: FormKitType) -> bool:
259 """
260 Check if this converter can convert the given node.
262 Args:
263 node: The FormKit node to check
265 Returns:
266 True if the node is a number-based node, False otherwise
267 """
268 if not hasattr(node, "formkit"):
269 return False
271 number_types = {"number", "tel"}
272 return node.formkit in number_types
274 def to_pydantic_type(self, node: FormKitType) -> str:
275 """
276 Convert the node to a Pydantic type string.
278 Args:
279 node: The FormKit node to convert
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):
286 return "int"
288 # NumberNode: check step attribute
289 if isinstance(node, NumberNode):
290 if node.step is not None:
291 return "float"
292 return "int"
294 # Fallback (shouldn't happen if can_convert is correct)
295 return "int"
298class DateConverter(BaseConverter):
299 """
300 Converter for date-based FormKit nodes.
302 Handles: datepicker, date
303 Returns: "date" for both datepicker and date nodes (generates DateField)
304 """
306 @property
307 def validators(self) -> list[str]:
308 return []
310 @property
311 def extra_imports(self) -> list[str]:
312 return []
314 def to_django_type(self, node: FormKitType) -> str:
315 return "DateField"
317 def to_django_args(self, node: FormKitType) -> dict[str, str]:
318 return {"null": "True", "blank": "True"}
320 def can_convert(self, node: FormKitType) -> bool:
321 """
322 Check if this converter can convert the given node.
324 Args:
325 node: The FormKit node to check
327 Returns:
328 True if the node is a date-based node, False otherwise
329 """
330 if not hasattr(node, "formkit"):
331 return False
333 date_types = {"datepicker", "date"}
334 return node.formkit in date_types
336 def to_pydantic_type(self, node: FormKitType) -> str:
337 """
338 Convert the node to a Pydantic type string.
340 Args:
341 node: The FormKit node to convert
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"
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"
359 # Default fallback (shouldn't happen if can_convert is correct)
360 return "date"
363class BooleanConverter(BaseConverter):
364 """
365 Converter for boolean FormKit nodes.
367 Handles: checkbox
368 Returns: "bool"
369 """
371 @property
372 def validators(self) -> list[str]:
373 return []
375 @property
376 def extra_imports(self) -> list[str]:
377 return []
379 def to_django_type(self, node: FormKitType) -> str:
380 return "BooleanField"
382 def to_django_args(self, node: FormKitType) -> dict[str, str]:
383 return {"null": "True", "blank": "True"}
385 def can_convert(self, node: FormKitType) -> bool:
386 """
387 Check if this converter can convert the given node.
389 Args:
390 node: The FormKit node to check
392 Returns:
393 True if the node is a checkbox node, False otherwise
394 """
395 if not hasattr(node, "formkit"):
396 return False
398 return node.formkit == "checkbox"
400 def to_pydantic_type(self, node: FormKitType) -> str:
401 """
402 Convert the node to a Pydantic type string.
404 Args:
405 node: The FormKit node to convert
407 Returns:
408 "bool" for checkbox nodes
409 """
410 return "bool"
413class UuidConverter(BaseConverter):
414 """
415 Converter for UUID FormKit nodes.
417 Handles: uuid
418 Returns: "UUID"
419 """
421 @property
422 def validators(self) -> list[str]:
423 return []
425 @property
426 def extra_imports(self) -> list[str]:
427 return []
429 def to_django_type(self, node: FormKitType) -> str:
430 return "UUIDField"
432 def to_django_args(self, node: FormKitType) -> dict[str, str]:
433 return {"editable": "False", "null": "True", "blank": "True"}
435 def can_convert(self, node: FormKitType) -> bool:
436 """
437 Check if this converter can convert the given node.
439 Args:
440 node: The FormKit node to check
442 Returns:
443 True if the node is a uuid node, False otherwise
444 """
445 if not hasattr(node, "formkit"):
446 return False
448 return node.formkit == "uuid"
450 def to_pydantic_type(self, node: FormKitType) -> str:
451 """
452 Convert the node to a Pydantic type string.
454 Args:
455 node: The FormKit node to convert
457 Returns:
458 "UUID" for uuid nodes
459 """
460 return "UUID"
463class CurrencyConverter(BaseConverter):
464 """
465 Converter for currency FormKit nodes.
467 Handles: currency
468 Returns: "Decimal"
469 """
471 @property
472 def validators(self) -> list[str]:
473 return []
475 @property
476 def extra_imports(self) -> list[str]:
477 return ["from decimal import Decimal"]
479 def to_django_type(self, node: FormKitType) -> str:
480 return "DecimalField"
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 }
490 def can_convert(self, node: FormKitType) -> bool:
491 """
492 Check if this converter can convert the given node.
494 Args:
495 node: The FormKit node to check
497 Returns:
498 True if the node is a currency node, False otherwise
499 """
500 if not hasattr(node, "formkit"):
501 return False
503 return node.formkit == "currency"
505 def to_pydantic_type(self, node: FormKitType) -> str:
506 """
507 Convert the node to a Pydantic type string.
509 Args:
510 node: The FormKit node to convert
512 Returns:
513 "Decimal" for currency nodes
514 """
515 return "Decimal"
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)