Coverage for src/typedal/core.py: 100%

119 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-05 16:10 +0200

1""" 

2Core functionality of TypeDAL. 

3""" 

4 

5import datetime as dt 

6import types 

7import typing 

8from decimal import Decimal 

9 

10import pydal 

11from pydal.objects import Field, Query, Row, Rows, Table 

12 

13# use typing.cast(type, ...) to make mypy happy with unions 

14T_annotation = typing.Type[typing.Any] | types.UnionType 

15 

16BASIC_MAPPINGS: dict[T_annotation, str] = { 

17 str: "string", 

18 int: "integer", 

19 bool: "boolean", 

20 bytes: "blob", 

21 float: "double", 

22 object: "json", 

23 Decimal: "decimal(10,2)", 

24 dt.date: "date", 

25 dt.time: "time", 

26 dt.datetime: "datetime", 

27} 

28 

29 

30class _Types: 

31 """ 

32 Internal type storage for stuff that mypy otherwise won't understand. 

33 """ 

34 

35 NONETYPE = type(None) 

36 

37 

38# the input and output of TypeDAL.define 

39T = typing.TypeVar("T", typing.Type["TypedTable"], typing.Type["Table"]) 

40 

41 

42def is_union(some_type: type) -> bool: 

43 """ 

44 Check if a type is some type of Union. 

45 

46 Args: 

47 some_type: types.UnionType = type(int | str); typing.Union = typing.Union[int, str] 

48 

49 """ 

50 return typing.get_origin(some_type) in (types.UnionType, typing.Union) 

51 

52 

53class TypeDAL(pydal.DAL): # type: ignore 

54 """ 

55 Drop-in replacement for pyDAL with layer to convert class-based table definitions to classical pydal define_tables. 

56 """ 

57 

58 dal: Table 

59 

60 default_kwargs = { 

61 # fields are 'required' (notnull) by default: 

62 "notnull": True, 

63 } 

64 

65 def define(self, cls: T) -> Table: 

66 """ 

67 Can be used as a decorator on a class that inherits `TypedTable`, \ 

68 or as a regular method if you need to define your classes before you have access to a 'db' instance. 

69 

70 Args: 

71 cls: 

72 

73 Example: 

74 @db.define 

75 class Person(TypedTable): 

76 ... 

77 

78 class Article(TypedTable): 

79 ... 

80 

81 # at a later time: 

82 db.define(Article) 

83 

84 Returns: 

85 the result of pydal.define_table 

86 """ 

87 # when __future__.annotations is implemented, cls.__annotations__ will not work anymore as below. 

88 # proper way to handle this would be (but gives error right now due to Table implementing magic methods): 

89 # typing.get_type_hints(cls, globalns=None, localns=None) 

90 

91 # dirty way (with evil eval): 

92 # [eval(v) for k, v in cls.__annotations__.items()] 

93 # this however also stops working when variables outside this scope or even references to other 

94 # objects are used. So for now, this package will NOT work when from __future__ import annotations is used, 

95 # and might break in the future, when this annotations behavior is enabled by default. 

96 

97 # non-annotated variables have to be passed to define_table as kwargs 

98 

99 tablename = self._to_snake(cls.__name__) 

100 fields = [self._to_field(fname, ftype) for fname, ftype in cls.__annotations__.items()] 

101 other_kwargs = {k: v for k, v in cls.__dict__.items() if k not in cls.__annotations__ and not k.startswith("_")} 

102 

103 table: Table = self.define_table(tablename, *fields, **other_kwargs) 

104 

105 cls.__set_internals__(db=self, table=table) 

106 

107 # the ACTUAL output is not TypedTable but rather pydal.Table 

108 # but telling the editor it is T helps with hinting. 

109 return table 

110 

111 def __call__(self, *_args: Query, **kwargs: typing.Any) -> pydal.objects.Set: 

112 """ 

113 A db instance can be called directly to perform a query. 

114 

115 Usually, only a query is passed 

116 

117 Example: 

118 db(query).select() 

119 

120 """ 

121 args = list(_args) 

122 if args: 

123 cls = args[0] 

124 if issubclass(type(cls), type) and issubclass(cls, TypedTable): 

125 # table defined without @db.define decorator! 

126 args[0] = cls.id != None 

127 

128 return super().__call__(*args, **kwargs) 

129 

130 # todo: insert etc shadowen? 

131 

132 @classmethod 

133 def _build_field(cls, name: str, _type: str, **kw: typing.Any) -> Field: 

134 return Field(name, _type, **{**cls.default_kwargs, **kw}) 

135 

136 @classmethod 

137 def _annotation_to_pydal_fieldtype( 

138 cls, _ftype: T_annotation, mut_kw: typing.MutableMapping[str, typing.Any] 

139 ) -> typing.Optional[str]: 

140 # ftype can be a union or type. typing.cast is sometimes used to tell mypy when it's not a union. 

141 ftype = typing.cast(type, _ftype) # cast from typing.Type to type to make mypy happy) 

142 

143 if mapping := BASIC_MAPPINGS.get(ftype): 

144 # basi types 

145 return mapping 

146 elif isinstance(ftype, Table): 

147 # db.table 

148 return f"reference {ftype._tablename}" 

149 elif issubclass(type(ftype), type) and issubclass(ftype, TypedTable): 

150 # SomeTable 

151 snakename = cls._to_snake(ftype.__name__) 

152 return f"reference {snakename}" 

153 elif isinstance(ftype, TypedFieldType): 

154 # FieldType(type, ...) 

155 return ftype._to_field(mut_kw) 

156 elif isinstance(ftype, types.GenericAlias) and typing.get_origin(ftype) is list: 

157 # list[str] -> str -> string -> list:string 

158 _child_type = typing.get_args(ftype)[0] 

159 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

160 return f"list:{_child_type}" 

161 elif is_union(ftype): 

162 # str | int -> UnionType 

163 # typing.Union[str | int] -> typing._UnionGenericAlias 

164 

165 # typing.Optional[type] == type | None 

166 

167 match typing.get_args(ftype): 

168 case (_child_type, _Types.NONETYPE): 

169 # good union of Nullable 

170 

171 # if a field is optional, it is nullable: 

172 mut_kw["notnull"] = False 

173 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

174 case _: 

175 # two types is not supported by the db! 

176 return None 

177 else: 

178 return None 

179 

180 @classmethod 

181 def _to_field(cls, fname: str, ftype: type, **kw: typing.Any) -> Field: 

182 """ 

183 Convert a annotation into a pydal Field. 

184 

185 Args: 

186 fname: name of the property 

187 ftype: annotation of the property 

188 kw: when using TypedField or a function returning it (e.g. StringField), 

189 keyword args can be used to pass any other settings you would normally to a pydal Field 

190 

191 -> pydal.Field(fname, ftype, **kw) 

192 

193 Example: 

194 class MyTable: 

195 fname: ftype 

196 id: int 

197 name: str 

198 reference: Table 

199 other: TypedField(str, default="John Doe") # default will be in kwargs 

200 """ 

201 fname = cls._to_snake(fname) 

202 

203 converted_type = cls._annotation_to_pydal_fieldtype(ftype, kw) 

204 if not converted_type: 

205 raise NotImplementedError(f"Unsupported type {ftype}/{type(ftype)}") 

206 

207 return cls._build_field(fname, converted_type, **kw) 

208 

209 @staticmethod 

210 def _to_snake(camel: str) -> str: 

211 # https://stackoverflow.com/a/44969381 

212 return "".join([f"_{c.lower()}" if c.isupper() else c for c in camel]).lstrip("_") 

213 

214 

215class TypedTableMeta(type): 

216 """ 

217 Meta class allows getattribute on class variables instead instance variables. 

218 

219 Used in `class TypedTable(Table, metaclass=TypedTableMeta)` 

220 """ 

221 

222 def __getattr__(self, key: str) -> Field: 

223 """ 

224 The getattr method is only called when getattribute can't find something. 

225 

226 `__get_table_column__` is defined in `TypedTable` 

227 """ 

228 return self.__get_table_column__(key) 

229 

230 

231class TypedTable(Table, metaclass=TypedTableMeta): # type: ignore 

232 """ 

233 Typed version of pydal.Table, does not really do anything itself but forwards logic to pydal. 

234 """ 

235 

236 id: int # noqa: 'id' has to be id since that's the db column 

237 

238 # set up by db.define: 

239 __db: TypeDAL | None = None 

240 __table: Table | None = None 

241 

242 @classmethod 

243 def __set_internals__(cls, db: pydal.DAL, table: Table) -> None: 

244 """ 

245 Store the related database and pydal table for later usage. 

246 """ 

247 cls.__db = db 

248 cls.__table = table 

249 

250 @classmethod 

251 def __get_table_column__(cls, col: str) -> Field: 

252 """ 

253 Magic method used by TypedTableMeta to get a database field with dot notation on a class. 

254 

255 Example: 

256 SomeTypedTable.col -> db.table.col (via TypedTableMeta.__getattr__) 

257 

258 """ 

259 # 

260 if cls.__table: 

261 return cls.__table[col] 

262 

263 def __new__(cls, *a: typing.Any, **kw: typing.Any) -> Row: # or none! 

264 """ 

265 When e.g. Table(id=0) is called without db.define, \ 

266 this catches it and forwards for proper behavior. 

267 

268 Args: 

269 *a: can be for example Table(<id>) 

270 **kw: can be for example Table(slug=<slug>) 

271 """ 

272 if not cls.__table: 

273 raise EnvironmentError("@define or db.define is not called on this class yet!") 

274 return cls.__table(*a, **kw) 

275 

276 @classmethod 

277 def insert(cls, **fields: typing.Any) -> int: 

278 """ 

279 This is only called when db.define is not used as a decorator. 

280 

281 cls.__table functions as 'self' 

282 

283 Args: 

284 **fields: anything you want to insert in the database 

285 

286 Returns: the ID of the new row. 

287 

288 """ 

289 if not cls.__table: 

290 raise EnvironmentError("@define or db.define is not called on this class yet!") 

291 

292 result = super().insert(cls.__table, **fields) 

293 # it already is an int but mypy doesn't understand that 

294 return typing.cast(int, result) 

295 

296 

297# backwards compat: 

298TypedRow = TypedTable 

299 

300 

301class TypedFieldType(Field): # type: ignore 

302 """ 

303 Typed version of pydal.Field, which will be converted to a normal Field in the background. 

304 """ 

305 

306 _table = "<any table>" 

307 _type: T_annotation 

308 kwargs: typing.Any 

309 

310 def __init__(self, _type: T_annotation, **kwargs: typing.Any) -> None: 

311 """ 

312 A TypedFieldType should not be inited manually, but TypedField (from `fields.py`) should be used! 

313 """ 

314 self._type = _type 

315 self.kwargs = kwargs 

316 

317 def __str__(self) -> str: 

318 """ 

319 String representation of a Typed Field. 

320 

321 If `type` is set explicitly (e.g. TypedField(str, type="text")), that type is used: `TypedField.text`, 

322 otherwise the type annotation is used (e.g. TypedField(str) -> TypedField.str) 

323 """ 

324 if "type" in self.kwargs: 

325 # manual type in kwargs supplied 

326 t = self.kwargs["type"] 

327 elif issubclass(type, type(self._type)): 

328 # normal type, str.__name__ = 'str' 

329 t = getattr(self._type, "__name__", str(self._type)) 

330 elif t_args := typing.get_args(self._type): 

331 # list[str] -> 'str' 

332 t = t_args[0].__name__ 

333 else: # pragma: no cover 

334 # fallback - something else, may not even happen, I'm not sure 

335 t = self._type 

336 return f"TypedField.{t}" 

337 

338 def __repr__(self) -> str: 

339 """ 

340 More detailed string representation of a Typed Field. 

341 

342 Uses __str__ and adds the provided extra options (kwargs) in the representation. 

343 """ 

344 s = self.__str__() 

345 kw = self.kwargs.copy() 

346 kw.pop("type", None) 

347 return f"<{s} with options {kw}>" 

348 

349 def _to_field(self, extra_kwargs: typing.MutableMapping[str, typing.Any]) -> typing.Optional[str]: 

350 """ 

351 Convert a Typed Field instance to a pydal.Field. 

352 """ 

353 other_kwargs = self.kwargs.copy() 

354 extra_kwargs.update(other_kwargs) 

355 return extra_kwargs.pop("type", False) or TypeDAL._annotation_to_pydal_fieldtype(self._type, extra_kwargs) 

356 

357 

358S = typing.TypeVar("S") 

359 

360 

361class TypedRows(typing.Collection[S], Rows): # type: ignore 

362 """ 

363 Can be used as the return type of a .select(). 

364 

365 Example: 

366 people: TypedRows[Person] = db(Person).select() 

367 """