Coverage for formkit_ninja/parser/type_convert.py: 25%

1 from __future__ import annotations

2

3 import warnings

4 from keyword import iskeyword

5 from typing import Generator, Iterable, Literal, cast

6

7 from formkit_ninja import formkit_schema

8 from formkit_ninja.formkit_schema import FormKitNode, GroupNode, RepeaterNode

9

10 FormKitType = formkit_schema.FormKitType

11

12

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

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