Coverage for formkit_ninja / parser / type_convert.py: 25%
240 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-23 06:00 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-23 06:00 +0000
1from __future__ import annotations
3import warnings
4from keyword import iskeyword
5from typing import Generator, Iterable, Literal, cast
7from formkit_ninja import formkit_schema
8from formkit_ninja.formkit_schema import FormKitNode, GroupNode, RepeaterNode
10FormKitType = formkit_schema.FormKitType
13def make_valid_identifier(input_string: str):
14 """
15 Replace invalid characters with underscores
16 Remove trailing / leading digits
17 Remove trailing/leading underscores
18 Lowercase
19 """
20 try:
21 output = "".join(ch if ch.isalnum() else "_" for ch in input_string)
23 while output[-1].isdigit():
24 output = output[:-1]
26 while output[0].isdigit():
27 output = output[1:]
29 while output[-1] == "_":
30 output = output[:-1]
32 while output[0] == "_":
33 output = output[1:]
34 except IndexError:
35 raise TypeError(f"The name {input_string} couldn't be used as an identifier")
37 return output.lower()
40class NodePath:
41 """
42 Mostly a wrapper around "tuple" to provide useful conventions
43 for naming
44 """
46 def __init__(self, *nodes: FormKitType):
47 self.nodes = nodes
49 @classmethod
50 def from_obj(cls, obj: dict):
51 node = FormKitNode.parse_obj(obj).__root__
52 # node can be a string or a FormKitSchemaNode
53 # NodePath expects FormKitType (which is the union of nodes)
54 return cls(cast(FormKitType, node))
56 def __truediv__(self, node: Literal[".."] | FormKitType):
57 """
58 This overrides the builtin '/' operator, like "Path", to allow appending nodes
59 """
60 if node == "..":
61 return self.__class__(*self.nodes[:-1])
62 return self.__class__(*self.nodes, cast(formkit_schema.FormKitType, node))
64 def suggest_model_name(self) -> str:
65 """
66 Single reference for table name and foreign key references
67 """
68 model_name = "".join(map(self.safe_node_name, self.nodes))
69 return model_name
71 def suggest_class_name(self):
72 model_name = "".join(map(lambda n: n.capitalize(), map(self.safe_node_name, self.nodes)))
73 return model_name
75 def suggest_field_name(self):
76 """
77 Single reference for table name and foreign key references
78 """
79 return self.safe_node_name(self.node)
81 def suggest_link_class_name(self):
82 return f"{self.classname}Link"
84 # Some accessors for the functions above
86 @property
87 def modelname(self):
88 return self.suggest_model_name()
90 @property
91 def classname(self):
92 return self.suggest_class_name()
94 @property
95 def fieldname(self):
96 return self.suggest_field_name()
98 @property
99 def linkname(self):
100 return self.suggest_link_class_name()
102 @property
103 def classname_lower(self):
104 return self.classname.lower()
106 @property
107 def classname_schema(self):
108 return f"{self.classname}Schema"
110 @staticmethod
111 def safe_name(name: str, fix: bool = True) -> str:
112 """
113 Ensure that the "name" provided is a valid
114 python identifier, correct if necessary
115 """
116 if name is None:
117 raise TypeError
118 if not name.isidentifier() or iskeyword(name):
119 if fix:
120 warnings.warn(f"The name: '''{name}''' is not a valid identifier")
121 # Run again to check that it's not a keyword
122 return NodePath.safe_name(make_valid_identifier(name), fix=False)
123 else:
124 raise KeyError(f"The name: '''{name}''' is not a valid identifier")
125 return name
127 def safe_node_name(self, node: FormKitType) -> str:
128 """
129 Return either the "name" or "id" field
130 """
131 if node.name:
132 name = self.safe_name(node.name)
133 elif node.id:
134 name = self.safe_name(node.id)
135 else:
136 raise AttributeError("Could not determine a suitable 'name' for this node")
138 return name
140 @property
141 def is_repeater(self):
142 return isinstance(self.node, RepeaterNode)
144 @property
145 def is_group(self):
146 return isinstance(self.node, GroupNode)
148 @property
149 def formkits(self) -> Iterable["NodePath"]:
150 for n in self.children:
151 if hasattr(n, "formkit"):
152 yield self / n
154 @property
155 def formkits_not_repeaters(self) -> Iterable["NodePath"]:
156 def _get() -> Generator["NodePath", None, None]:
157 for n in self.children:
158 if hasattr(n, "formkit") and not isinstance(n, RepeaterNode):
159 yield self / n
161 return tuple(_get())
163 @property
164 def children(self):
165 return getattr(self.node, "children", []) or []
167 def filter_children(self, type_) -> Iterable["NodePath"]:
168 for n in self.children:
169 if isinstance(n, type_):
170 yield self / n
172 @property
173 def repeaters(self):
174 return tuple(self.filter_children(RepeaterNode))
176 @property
177 def groups(self):
178 return tuple(self.filter_children(GroupNode))
180 @property
181 def node(self):
182 return self.nodes[-1]
184 @property
185 def parent(self):
186 if len(self.nodes) > 1:
187 return self.nodes[-2]
188 else:
189 return None
191 @property
192 def is_child(self):
193 return self.parent is not None
195 @property
196 def depth(self):
197 return len(self.nodes)
199 @property
200 def tail(self):
201 return NodePath(self.node)
203 def __str__(self):
204 return f"NodePath {len(self.nodes)}: {self.node}"
206 @property
207 def django_attrib_name(self):
208 """
209 If not a group, return the Django field attribute
210 """
211 return self.tail.modelname
213 @property
214 def pydantic_attrib_name(self):
215 base = self.django_attrib_name
216 return base
218 @property
219 def parent_class_name(self):
220 return (self / "..").classname
222 def to_pydantic_type(self) -> Literal["str", "int", "bool", "Decimal", "float", "date"] | str:
223 """
224 Usually, this should return a well known Python type as a string
225 """
226 node = self.node
227 if node.formkit == "number":
228 if node.step is not None:
229 # We don't actually **know** this but it's a good assumption
230 return "float"
231 return "int"
233 match node.formkit:
234 case "text":
235 return "str"
236 case "number":
237 return "float"
238 case "select" | "dropdown" | "radio" | "autocomplete":
239 return "str"
240 case "datepicker":
241 return "datetime"
242 case "tel":
243 return "int"
244 case "group":
245 return self.classname
246 case "repeater":
247 return f"list[{self.classname}]"
248 case "hidden":
249 return "str"
250 return "str"
252 @property
253 def pydantic_type(self):
254 return self.to_pydantic_type()
256 def to_postgres_type(self):
257 match self.to_pydantic_type():
258 case "bool":
259 return "boolean"
260 case "str":
261 return "text"
262 case "Decimal":
263 return "NUMERIC(15,2)"
264 case "int":
265 return "int"
266 case "float":
267 return "float"
268 return "text"
270 @property
271 def postgres_type(self):
272 return self.to_postgres_type()
274 def to_django_type(self) -> str:
275 """
276 Return the "models.ModelField" which would match this data type
277 """
278 if self.is_group:
279 return "OneToOneField"
281 match self.to_pydantic_type():
282 case "bool":
283 return "BooleanField"
284 case "str":
285 return "TextField"
286 case "Decimal":
287 return "DecimalField"
288 case "int":
289 return "IntegerField"
290 case "float":
291 return "FloatField"
292 case "datetime":
293 return "DateTimeField"
294 case "date":
295 return "DateField"
296 case "UUID":
297 return "UUIDField"
298 return "TextField"
300 @property
301 def django_type(self):
302 return self.to_django_type()
304 def to_django_args(self) -> str:
305 if self.is_group:
306 return f"{self.classname}, on_delete=models.CASCADE"
308 match self.to_pydantic_type():
309 case "bool":
310 return "null=True, blank=True"
311 case "str":
312 return "null=True, blank=True"
313 case "Decimal":
314 return "max_digits=20, decimal_places=2, null=True, blank=True"
315 case "int":
316 return "null=True, blank=True"
317 case "float":
318 return "null=True, blank=True"
319 case "datetime":
320 return "null=True, blank=True"
321 case "date":
322 return "null=True, blank=True"
323 case "UUID":
324 return "editable=False, null=True, blank=True"
325 return "null=True, blank=True"
327 @property
328 def django_args(self):
329 return self.to_django_args()
331 @property
332 def extra_attribs(self):
333 """
334 Returns extra fields to be appended to this group or
335 repeater node in "models.py"
336 """
337 return []
339 @property
340 def extra_attribs_schema(self):
341 """
342 Returns extra attributes to be appended to "schema_out.py"
343 For Partisipa this included a foreign key to "Submission"
344 """
345 return []
347 @property
348 def extra_attribs_basemodel(self):
349 """
350 Returns extra attributes to be appended to "schema.py"
351 For Partisipa this included a foreign key to "Submission"
352 """
353 return []
355 @property
356 def validators(self) -> list[str]:
357 """
358 Hook to allow extra processing for
359 fields like Partisipa's 'currency' field
360 """
361 return []