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

144 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-27 17:50 +0200

1""" 

2Core functionality of TypeDAL. 

3""" 

4import datetime as dt 

5import types 

6import typing 

7from collections import ChainMap 

8from decimal import Decimal 

9 

10import pydal 

11from pydal.objects import Field, 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 

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 

73 

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

75 """ 

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

77 """ 

78 

79 dal: Table 

80 

81 default_kwargs: typing.ClassVar[typing.Dict[str, typing.Any]] = { 

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

83 "notnull": True, 

84 } 

85 

86 def define(self, cls: T) -> T: 

87 """ 

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

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

90 

91 Args: 

92 cls: 

93 

94 Example: 

95 @db.define 

96 class Person(TypedTable): 

97 ... 

98 

99 class Article(TypedTable): 

100 ... 

101 

102 # at a later time: 

103 db.define(Article) 

104 

105 Returns: 

106 the result of pydal.define_table 

107 """ 

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

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

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

111 

112 # dirty way (with evil eval): 

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

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

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

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

117 

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

119 

120 tablename = self._to_snake(cls.__name__) 

121 # grab annotations of cls and it's parents: 

122 annotations = all_annotations(cls) 

123 # extend with `prop = TypedField()` 'annotations': 

124 annotations |= {k: v for k, v in cls.__dict__.items() if isinstance(v, TypedFieldType)} 

125 # remove internal stuff: 

126 annotations = {k: v for k, v in annotations.items() if not k.startswith("_")} 

127 

128 typedfields = {k: v for k, v in annotations.items() if isinstance(v, TypedFieldType)} 

129 

130 fields = {fname: self._to_field(fname, ftype) for fname, ftype in annotations.items()} 

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

132 

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

134 

135 for name, typed_field in typedfields.items(): 

136 field = fields[name] 

137 typed_field.bind(field, table) 

138 

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

140 

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

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

143 return cls 

144 

145 def __call__(self, *_args: typing.Any, **kwargs: typing.Any) -> "TypedSet": 

146 """ 

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

148 

149 Usually, only a query is passed. 

150 

151 Example: 

152 db(query).select() 

153 

154 """ 

155 args = list(_args) 

156 if args: 

157 cls = args[0] 

158 if isinstance(cls, bool): 

159 raise ValueError("Don't actually pass a bool to db()! Use a query instead.") 

160 

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

162 # table defined without @db.define decorator! 

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

164 

165 _set = super().__call__(*args, **kwargs) 

166 return typing.cast(TypedSet, _set) 

167 

168 # todo: insert etc shadowen? 

169 

170 @classmethod 

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

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

173 

174 @classmethod 

175 def _annotation_to_pydal_fieldtype( 

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

177 ) -> typing.Optional[str]: 

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

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

180 

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

182 # basi types 

183 return mapping 

184 elif isinstance(ftype, Table): 

185 # db.table 

186 return f"reference {ftype._tablename}" 

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

188 # SomeTable 

189 snakename = cls._to_snake(ftype.__name__) 

190 return f"reference {snakename}" 

191 elif isinstance(ftype, TypedFieldType): 

192 # FieldType(type, ...) 

193 return ftype._to_field(mut_kw) 

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

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

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

197 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

198 return f"list:{_child_type}" 

199 elif is_union(ftype): 

200 # str | int -> UnionType 

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

202 

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

204 

205 match typing.get_args(ftype): 

206 case (_child_type, _Types.NONETYPE): 

207 # good union of Nullable 

208 

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

210 mut_kw["notnull"] = False 

211 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

212 case _: 

213 # two types is not supported by the db! 

214 return None 

215 else: 

216 return None 

217 

218 @classmethod 

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

220 """ 

221 Convert a annotation into a pydal Field. 

222 

223 Args: 

224 fname: name of the property 

225 ftype: annotation of the property 

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

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

228 

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

230 

231 Example: 

232 class MyTable: 

233 fname: ftype 

234 id: int 

235 name: str 

236 reference: Table 

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

238 """ 

239 fname = cls._to_snake(fname) 

240 

241 if converted_type := cls._annotation_to_pydal_fieldtype(ftype, kw): 

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

243 else: 

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

245 

246 @staticmethod 

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

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

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

250 

251 

252class TypedTableMeta(type): 

253 """ 

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

255 

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

257 """ 

258 

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

260 """ 

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

262 

263 `__get_table_column__` is defined in `TypedTable` 

264 """ 

265 return self.__get_table_column__(key) 

266 

267 

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

269 """ 

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

271 """ 

272 

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

274 

275 # set up by db.define: 

276 __db: TypeDAL | None = None 

277 __table: Table | None = None 

278 

279 @classmethod 

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

281 """ 

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

283 """ 

284 cls.__db = db 

285 cls.__table = table 

286 

287 @classmethod 

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

289 """ 

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

291 

292 Example: 

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

294 

295 """ 

296 # 

297 if cls.__table: 

298 return cls.__table[col] 

299 

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

301 """ 

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

303 this catches it and forwards for proper behavior. 

304 

305 Args: 

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

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

308 """ 

309 if not cls.__table: 

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

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

312 

313 @classmethod 

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

315 """ 

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

317 

318 cls.__table functions as 'self' 

319 

320 Args: 

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

322 

323 Returns: the ID of the new row. 

324 

325 """ 

326 if not cls.__table: 

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

328 

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

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

331 return typing.cast(int, result) 

332 

333 

334# backwards compat: 

335TypedRow = TypedTable 

336 

337 

338class TypedFieldType(Field): # type: ignore 

339 """ 

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

341 """ 

342 

343 # todo: .bind 

344 

345 # will be set by .bind on db.define 

346 name = "" 

347 _db = None 

348 _rname = None 

349 _table = None 

350 

351 _type: T_annotation 

352 kwargs: typing.Any 

353 

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

355 """ 

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

357 """ 

358 self._type = _type 

359 self.kwargs = kwargs 

360 

361 def __str__(self) -> str: 

362 """ 

363 String representation of a Typed Field. 

364 

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

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

367 """ 

368 if "type" in self.kwargs: 

369 # manual type in kwargs supplied 

370 t = self.kwargs["type"] 

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

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

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

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

375 # list[str] -> 'str' 

376 t = t_args[0].__name__ 

377 else: # pragma: no cover 

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

379 t = self._type 

380 return f"TypedField.{t}" 

381 

382 def __repr__(self) -> str: 

383 """ 

384 More detailed string representation of a Typed Field. 

385 

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

387 """ 

388 s = self.__str__() 

389 kw = self.kwargs.copy() 

390 kw.pop("type", None) 

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

392 

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

394 """ 

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

396 """ 

397 other_kwargs = self.kwargs.copy() 

398 extra_kwargs.update(other_kwargs) 

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

400 

401 def bind(self, field: Field, table: Table) -> None: 

402 """ 

403 Bind the right db/table/field info to this class, so queries can be made using `Class.field == ...`. 

404 """ 

405 self.name = field.name 

406 self.type = field.type 

407 super().bind(table) 

408 

409 # def __eq__(self, value): 

410 # return Query(self.db, self._dialect.eq, self, value) 

411 

412 

413S = typing.TypeVar("S") 

414 

415 

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

417 """ 

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

419 

420 Example: 

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

422 """ 

423 

424 

425T_Table = typing.TypeVar("T_Table", bound=Table) 

426 

427 

428class TypedSet(pydal.objects.Set): # type: ignore # pragma: no cover 

429 """ 

430 Used to make pydal Set more typed. 

431 

432 This class is not actually used, only 'cast' by TypeDAL.__call__ 

433 """ 

434 

435 def count(self, distinct: bool = None, cache: dict[str, typing.Any] = None) -> int: 

436 """ 

437 Count returns an int. 

438 """ 

439 result = super().count(distinct, cache) 

440 return typing.cast(int, result) 

441 

442 def select(self, *fields: typing.Any, **attributes: typing.Any) -> TypedRows[T_Table]: 

443 """ 

444 Select returns a TypedRows of a user defined table. 

445 

446 Example: 

447 result: TypedRows[MyTable] = db(MyTable.id > 0).select() 

448 

449 for row in result: 

450 typing.reveal_type(row) # MyTable 

451 """ 

452 rows = super().select(*fields, **attributes) 

453 return typing.cast(TypedRows[T_Table], rows)