Coverage for parser / type_convert.py: 0%

240 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-22 07:15 +0000

1from __future__ import annotations 

2 

3import warnings 

4from keyword import iskeyword 

5from typing import Generator, Iterable, Literal, cast 

6 

7from formkit_ninja import formkit_schema 

8from formkit_ninja.formkit_schema import FormKitNode, GroupNode, RepeaterNode 

9 

10FormKitType = formkit_schema.FormKitType 

11 

12 

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) 

22 

23 while output[-1].isdigit(): 

24 output = output[:-1] 

25 

26 while output[0].isdigit(): 

27 output = output[1:] 

28 

29 while output[-1] == "_": 

30 output = output[:-1] 

31 

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

36 

37 return output.lower() 

38 

39 

40class NodePath: 

41 """ 

42 Mostly a wrapper around "tuple" to provide useful conventions 

43 for naming 

44 """ 

45 

46 def __init__(self, *nodes: FormKitType): 

47 self.nodes = nodes 

48 

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

55 

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

63 

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 

70 

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 

74 

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) 

80 

81 def suggest_link_class_name(self): 

82 return f"{self.classname}Link" 

83 

84 # Some accessors for the functions above 

85 

86 @property 

87 def modelname(self): 

88 return self.suggest_model_name() 

89 

90 @property 

91 def classname(self): 

92 return self.suggest_class_name() 

93 

94 @property 

95 def fieldname(self): 

96 return self.suggest_field_name() 

97 

98 @property 

99 def linkname(self): 

100 return self.suggest_link_class_name() 

101 

102 @property 

103 def classname_lower(self): 

104 return self.classname.lower() 

105 

106 @property 

107 def classname_schema(self): 

108 return f"{self.classname}Schema" 

109 

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 

126 

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

137 

138 return name 

139 

140 @property 

141 def is_repeater(self): 

142 return isinstance(self.node, RepeaterNode) 

143 

144 @property 

145 def is_group(self): 

146 return isinstance(self.node, GroupNode) 

147 

148 @property 

149 def formkits(self) -> Iterable["NodePath"]: 

150 for n in self.children: 

151 if hasattr(n, "formkit"): 

152 yield self / n 

153 

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 

160 

161 return tuple(_get()) 

162 

163 @property 

164 def children(self): 

165 return getattr(self.node, "children", []) or [] 

166 

167 def filter_children(self, type_) -> Iterable["NodePath"]: 

168 for n in self.children: 

169 if isinstance(n, type_): 

170 yield self / n 

171 

172 @property 

173 def repeaters(self): 

174 return tuple(self.filter_children(RepeaterNode)) 

175 

176 @property 

177 def groups(self): 

178 return tuple(self.filter_children(GroupNode)) 

179 

180 @property 

181 def node(self): 

182 return self.nodes[-1] 

183 

184 @property 

185 def parent(self): 

186 if len(self.nodes) > 1: 

187 return self.nodes[-2] 

188 else: 

189 return None 

190 

191 @property 

192 def is_child(self): 

193 return self.parent is not None 

194 

195 @property 

196 def depth(self): 

197 return len(self.nodes) 

198 

199 @property 

200 def tail(self): 

201 return NodePath(self.node) 

202 

203 def __str__(self): 

204 return f"NodePath {len(self.nodes)}: {self.node}" 

205 

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 

212 

213 @property 

214 def pydantic_attrib_name(self): 

215 base = self.django_attrib_name 

216 return base 

217 

218 @property 

219 def parent_class_name(self): 

220 return (self / "..").classname 

221 

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" 

232 

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" 

251 

252 @property 

253 def pydantic_type(self): 

254 return self.to_pydantic_type() 

255 

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" 

269 

270 @property 

271 def postgres_type(self): 

272 return self.to_postgres_type() 

273 

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" 

280 

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" 

299 

300 @property 

301 def django_type(self): 

302 return self.to_django_type() 

303 

304 def to_django_args(self) -> str: 

305 if self.is_group: 

306 return f"{self.classname}, on_delete=models.CASCADE" 

307 

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" 

326 

327 @property 

328 def django_args(self): 

329 return self.to_django_args() 

330 

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 [] 

338 

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 [] 

346 

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 [] 

354 

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 []