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

1"""

2 Core functionality of TypeDAL.

3"""

4

5 import datetime as dt

6 import types

7 import typing

8 from collections import ChainMap

9 from decimal import Decimal

10

11 import pydal

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

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

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

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

41

42

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

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

61 def 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 class 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

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

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

322 TypedRow = TypedTable

323

324

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

382 S = typing.TypeVar("S")

383

384

385 class 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 """