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

1"""

2 Core functionality of TypeDAL.

3"""

4 import datetime as dt

5 import types

6 import typing

7 from collections import ChainMap

8 from decimal import Decimal

9

10 import pydal

11 from pydal._globals import DEFAULT

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

13

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

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

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

17

18 BASIC_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

32 class _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

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

42

43

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

55 def _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

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

76 class 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

255 class 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

271 class 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:

349 TypedRow = TypedTable

350

351

352 class 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

427 S = typing.TypeVar("S")

428

429

430 class 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

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

440

441

442 class 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)