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

124 statements  

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

1""" 

2Core functionality of TypeDAL. 

3""" 

4 

5import datetime as dt 

6import types 

7import typing 

8from collections import ChainMap 

9from decimal import Decimal 

10 

11import pydal 

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

13 

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

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

16 

17BASIC_MAPPINGS: dict[T_annotation, str] = { 

18 str: "string", 

19 int: "integer", 

20 bool: "boolean", 

21 bytes: "blob", 

22 float: "double", 

23 object: "json", 

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

25 dt.date: "date", 

26 dt.time: "time", 

27 dt.datetime: "datetime", 

28} 

29 

30 

31class _Types: 

32 """ 

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

34 """ 

35 

36 NONETYPE = type(None) 

37 

38 

39# the input and output of TypeDAL.define 

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

41 

42 

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

44 """ 

45 Check if a type is some type of Union. 

46 

47 Args: 

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

49 

50 """ 

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

52 

53def _all_annotations(cls: type) -> ChainMap[str, type]: 

54 """ 

55 Returns a dictionary-like ChainMap that includes annotations for all \ 

56 attributes defined in cls or inherited from superclasses. 

57 """ 

58 return ChainMap(*(c.__annotations__ for c in getattr(cls, "__mro__", []) if "__annotations__" in c.__dict__)) 

59 

60 

61def all_annotations(cls: type, _except: typing.Iterable[str] = None) -> dict[str, type]: 

62 """ 

63 Wrapper around `_all_annotations` that filters away any keys in _except. 

64 

65 It also flattens the ChainMap to a regular dict. 

66 """ 

67 if _except is None: 

68 _except = set() 

69 

70 _all = _all_annotations(cls) 

71 return {k: v for k, v in _all.items() if k not in _except} 

72 

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

74 """ 

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

76 """ 

77 

78 dal: Table 

79 

80 default_kwargs = { 

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

82 "notnull": True, 

83 } 

84 

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

86 """ 

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

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

89 

90 Args: 

91 cls: 

92 

93 Example: 

94 @db.define 

95 class Person(TypedTable): 

96 ... 

97 

98 class Article(TypedTable): 

99 ... 

100 

101 # at a later time: 

102 db.define(Article) 

103 

104 Returns: 

105 the result of pydal.define_table 

106 """ 

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

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

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

110 

111 # dirty way (with evil eval): 

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

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

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

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

116 

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

118 

119 tablename = self._to_snake(cls.__name__) 

120 annotations = all_annotations(cls) 

121 

122 raise ValueError(annotations, cls.__dict__) 

123 

124 fields = [self._to_field(fname, ftype) for fname, ftype in annotations.items()] 

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

126 

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

128 

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

130 

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

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

133 return table 

134 

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

136 """ 

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

138 

139 Usually, only a query is passed 

140 

141 Example: 

142 db(query).select() 

143 

144 """ 

145 args = list(_args) 

146 if args: 

147 cls = args[0] 

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

149 # table defined without @db.define decorator! 

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

151 

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

153 

154 # todo: insert etc shadowen? 

155 

156 @classmethod 

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

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

159 

160 @classmethod 

161 def _annotation_to_pydal_fieldtype( 

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

163 ) -> typing.Optional[str]: 

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

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

166 

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

168 # basi types 

169 return mapping 

170 elif isinstance(ftype, Table): 

171 # db.table 

172 return f"reference {ftype._tablename}" 

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

174 # SomeTable 

175 snakename = cls._to_snake(ftype.__name__) 

176 return f"reference {snakename}" 

177 elif isinstance(ftype, TypedFieldType): 

178 # FieldType(type, ...) 

179 return ftype._to_field(mut_kw) 

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

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

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

183 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

184 return f"list:{_child_type}" 

185 elif is_union(ftype): 

186 # str | int -> UnionType 

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

188 

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

190 

191 match typing.get_args(ftype): 

192 case (_child_type, _Types.NONETYPE): 

193 # good union of Nullable 

194 

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

196 mut_kw["notnull"] = False 

197 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

198 case _: 

199 # two types is not supported by the db! 

200 return None 

201 else: 

202 return None 

203 

204 @classmethod 

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

206 """ 

207 Convert a annotation into a pydal Field. 

208 

209 Args: 

210 fname: name of the property 

211 ftype: annotation of the property 

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

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

214 

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

216 

217 Example: 

218 class MyTable: 

219 fname: ftype 

220 id: int 

221 name: str 

222 reference: Table 

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

224 """ 

225 fname = cls._to_snake(fname) 

226 

227 converted_type = cls._annotation_to_pydal_fieldtype(ftype, kw) 

228 if not converted_type: 

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

230 

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

232 

233 @staticmethod 

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

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

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

237 

238 

239class TypedTableMeta(type): 

240 """ 

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

242 

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

244 """ 

245 

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

247 """ 

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

249 

250 `__get_table_column__` is defined in `TypedTable` 

251 """ 

252 return self.__get_table_column__(key) 

253 

254 

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

256 """ 

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

258 """ 

259 

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

261 

262 # set up by db.define: 

263 __db: TypeDAL | None = None 

264 __table: Table | None = None 

265 

266 @classmethod 

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

268 """ 

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

270 """ 

271 cls.__db = db 

272 cls.__table = table 

273 

274 @classmethod 

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

276 """ 

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

278 

279 Example: 

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

281 

282 """ 

283 # 

284 if cls.__table: 

285 return cls.__table[col] 

286 

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

288 """ 

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

290 this catches it and forwards for proper behavior. 

291 

292 Args: 

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

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

295 """ 

296 if not cls.__table: 

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

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

299 

300 @classmethod 

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

302 """ 

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

304 

305 cls.__table functions as 'self' 

306 

307 Args: 

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

309 

310 Returns: the ID of the new row. 

311 

312 """ 

313 if not cls.__table: 

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

315 

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

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

318 return typing.cast(int, result) 

319 

320 

321# backwards compat: 

322TypedRow = TypedTable 

323 

324 

325class TypedFieldType(Field): # type: ignore 

326 """ 

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

328 """ 

329 

330 _table = "<any table>" 

331 _type: T_annotation 

332 kwargs: typing.Any 

333 

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

335 """ 

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

337 """ 

338 self._type = _type 

339 self.kwargs = kwargs 

340 

341 def __str__(self) -> str: 

342 """ 

343 String representation of a Typed Field. 

344 

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

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

347 """ 

348 if "type" in self.kwargs: 

349 # manual type in kwargs supplied 

350 t = self.kwargs["type"] 

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

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

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

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

355 # list[str] -> 'str' 

356 t = t_args[0].__name__ 

357 else: # pragma: no cover 

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

359 t = self._type 

360 return f"TypedField.{t}" 

361 

362 def __repr__(self) -> str: 

363 """ 

364 More detailed string representation of a Typed Field. 

365 

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

367 """ 

368 s = self.__str__() 

369 kw = self.kwargs.copy() 

370 kw.pop("type", None) 

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

372 

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

374 """ 

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

376 """ 

377 other_kwargs = self.kwargs.copy() 

378 extra_kwargs.update(other_kwargs) 

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

380 

381 

382S = typing.TypeVar("S") 

383 

384 

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

386 """ 

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

388 

389 Example: 

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

391 """