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

153 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-27 18:15 +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._globals import DEFAULT 

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 

16T_Query = typing.Union["Table", "Query", "bool", "None", "TypedTable", typing.Type["TypedTable"]] 

17 

18BASIC_MAPPINGS: dict[T_annotation, str] = { 

19 str: "string", 

20 int: "integer", 

21 bool: "boolean", 

22 bytes: "blob", 

23 float: "double", 

24 object: "json", 

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

26 dt.date: "date", 

27 dt.time: "time", 

28 dt.datetime: "datetime", 

29} 

30 

31 

32class _Types: 

33 """ 

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

35 """ 

36 

37 NONETYPE = type(None) 

38 

39 

40# the input and output of TypeDAL.define 

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

42 

43 

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

45 """ 

46 Check if a type is some type of Union. 

47 

48 Args: 

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

50 

51 """ 

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

53 

54 

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

56 """ 

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

58 attributes defined in cls or inherited from superclasses. 

59 """ 

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

61 

62 

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

64 """ 

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

66 

67 It also flattens the ChainMap to a regular dict. 

68 """ 

69 if _except is None: 

70 _except = set() 

71 

72 _all = _all_annotations(cls) 

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

74 

75 

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

77 """ 

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

79 """ 

80 

81 dal: Table 

82 

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

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

85 "notnull": True, 

86 } 

87 

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

89 """ 

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

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

92 

93 Args: 

94 cls: 

95 

96 Example: 

97 @db.define 

98 class Person(TypedTable): 

99 ... 

100 

101 class Article(TypedTable): 

102 ... 

103 

104 # at a later time: 

105 db.define(Article) 

106 

107 Returns: 

108 the result of pydal.define_table 

109 """ 

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

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

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

113 

114 # dirty way (with evil eval): 

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

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

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

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

119 

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

121 

122 tablename = self._to_snake(cls.__name__) 

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

124 annotations = all_annotations(cls) 

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

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

127 # remove internal stuff: 

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

129 

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

131 

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

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

134 

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

136 

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

138 field = fields[name] 

139 typed_field.bind(field, table) 

140 

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

142 

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

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

145 return cls 

146 

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

148 """ 

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

150 

151 Usually, only a query is passed. 

152 

153 Example: 

154 db(query).select() 

155 

156 """ 

157 args = list(_args) 

158 if args: 

159 cls = args[0] 

160 if isinstance(cls, bool): 

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

162 

163 if isinstance(cls, type) and issubclass(type(cls), type) and issubclass(cls, TypedTable): 

164 # table defined without @db.define decorator! 

165 _cls: typing.Type[TypedTable] = cls 

166 args[0] = _cls.id != None 

167 

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

169 return typing.cast(TypedSet, _set) 

170 

171 # todo: insert etc shadowen? 

172 

173 @classmethod 

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

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

176 

177 @classmethod 

178 def _annotation_to_pydal_fieldtype( 

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

180 ) -> typing.Optional[str]: 

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

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

183 

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

185 # basi types 

186 return mapping 

187 elif isinstance(ftype, Table): 

188 # db.table 

189 return f"reference {ftype._tablename}" 

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

191 # SomeTable 

192 snakename = cls._to_snake(ftype.__name__) 

193 return f"reference {snakename}" 

194 elif isinstance(ftype, TypedFieldType): 

195 # FieldType(type, ...) 

196 return ftype._to_field(mut_kw) 

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

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

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

200 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

201 return f"list:{_child_type}" 

202 elif is_union(ftype): 

203 # str | int -> UnionType 

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

205 

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

207 

208 match typing.get_args(ftype): 

209 case (_child_type, _Types.NONETYPE): 

210 # good union of Nullable 

211 

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

213 mut_kw["notnull"] = False 

214 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

215 case _: 

216 # two types is not supported by the db! 

217 return None 

218 else: 

219 return None 

220 

221 @classmethod 

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

223 """ 

224 Convert a annotation into a pydal Field. 

225 

226 Args: 

227 fname: name of the property 

228 ftype: annotation of the property 

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

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

231 

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

233 

234 Example: 

235 class MyTable: 

236 fname: ftype 

237 id: int 

238 name: str 

239 reference: Table 

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

241 """ 

242 fname = cls._to_snake(fname) 

243 

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

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

246 else: 

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

248 

249 @staticmethod 

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

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

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

253 

254 

255class TypedTableMeta(type): 

256 """ 

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

258 

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

260 """ 

261 

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

263 """ 

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

265 

266 `__get_table_column__` is defined in `TypedTable` 

267 """ 

268 return self.__get_table_column__(key) 

269 

270 

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

272 """ 

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

274 """ 

275 

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

277 

278 # set up by db.define: 

279 __db: TypeDAL | None = None 

280 __table: Table | None = None 

281 

282 @classmethod 

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

284 """ 

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

286 """ 

287 cls.__db = db 

288 cls.__table = table 

289 

290 @classmethod 

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

292 """ 

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

294 

295 Example: 

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

297 

298 """ 

299 # 

300 if cls.__table: 

301 return cls.__table[col] 

302 

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

304 """ 

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

306 this catches it and forwards for proper behavior. 

307 

308 Args: 

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

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

311 """ 

312 if not cls.__table: 

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

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

315 

316 @classmethod 

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

318 """ 

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

320 

321 cls.__table functions as 'self' 

322 

323 Args: 

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

325 

326 Returns: the ID of the new row. 

327 

328 """ 

329 if not cls.__table: 

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

331 

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

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

334 return typing.cast(int, result) 

335 

336 @classmethod 

337 def update_or_insert(cls, query: T_Query = DEFAULT, **values: typing.Any) -> typing.Optional[int]: 

338 """ 

339 Add typing to pydal's update_or_insert. 

340 """ 

341 result = super().update_or_insert(cls, _key=query, **values) 

342 if result is None: 

343 return None 

344 else: 

345 return typing.cast(int, result) 

346 

347 

348# backwards compat: 

349TypedRow = TypedTable 

350 

351 

352class TypedFieldType(Field): # type: ignore 

353 """ 

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

355 """ 

356 

357 # todo: .bind 

358 

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

360 name = "" 

361 _db = None 

362 _rname = None 

363 _table = None 

364 

365 _type: T_annotation 

366 kwargs: typing.Any 

367 

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

369 """ 

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

371 """ 

372 self._type = _type 

373 self.kwargs = kwargs 

374 

375 def __str__(self) -> str: 

376 """ 

377 String representation of a Typed Field. 

378 

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

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

381 """ 

382 if "type" in self.kwargs: 

383 # manual type in kwargs supplied 

384 t = self.kwargs["type"] 

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

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

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

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

389 # list[str] -> 'str' 

390 t = t_args[0].__name__ 

391 else: # pragma: no cover 

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

393 t = self._type 

394 return f"TypedField.{t}" 

395 

396 def __repr__(self) -> str: 

397 """ 

398 More detailed string representation of a Typed Field. 

399 

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

401 """ 

402 s = self.__str__() 

403 kw = self.kwargs.copy() 

404 kw.pop("type", None) 

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

406 

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

408 """ 

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

410 """ 

411 other_kwargs = self.kwargs.copy() 

412 extra_kwargs.update(other_kwargs) 

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

414 

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

416 """ 

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

418 """ 

419 self.name = field.name 

420 self.type = field.type 

421 super().bind(table) 

422 

423 # def __eq__(self, value): 

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

425 

426 

427S = typing.TypeVar("S") 

428 

429 

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

431 """ 

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

433 

434 Example: 

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

436 """ 

437 

438 

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

440 

441 

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

443 """ 

444 Used to make pydal Set more typed. 

445 

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

447 """ 

448 

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

450 """ 

451 Count returns an int. 

452 """ 

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

454 return typing.cast(int, result) 

455 

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

457 """ 

458 Select returns a TypedRows of a user defined table. 

459 

460 Example: 

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

462 

463 for row in result: 

464 typing.reveal_type(row) # MyTable 

465 """ 

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

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