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
« 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
10import pydal
11from pydal.objects import Field, Row, Rows, Table
13# use typing.cast(type, ...) to make mypy happy with unions
14T_annotation = typing.Type[typing.Any] | types.UnionType
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}
30class _Types:
31 """
32 Internal type storage for stuff that mypy otherwise won't understand.
33 """
35 NONETYPE = type(None)
38# the input and output of TypeDAL.define
39T = typing.TypeVar("T", typing.Type["TypedTable"], typing.Type["Table"])
42def is_union(some_type: type) -> bool:
43 """
44 Check if a type is some type of Union.
46 Args:
47 some_type: types.UnionType = type(int | str); typing.Union = typing.Union[int, str]
49 """
50 return typing.get_origin(some_type) in (types.UnionType, typing.Union)
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__))
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.
65 It also flattens the ChainMap to a regular dict.
66 """
67 if _except is None:
68 _except = set()
70 _all = _all_annotations(cls)
71 return {k: v for k, v in _all.items() if k not in _except}
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 """
79 dal: Table
81 default_kwargs: typing.ClassVar[typing.Dict[str, typing.Any]] = {
82 # fields are 'required' (notnull) by default:
83 "notnull": True,
84 }
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.
91 Args:
92 cls:
94 Example:
95 @db.define
96 class Person(TypedTable):
97 ...
99 class Article(TypedTable):
100 ...
102 # at a later time:
103 db.define(Article)
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)
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.
118 # non-annotated variables have to be passed to define_table as kwargs
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("_")}
128 typedfields = {k: v for k, v in annotations.items() if isinstance(v, TypedFieldType)}
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("_")}
133 table: Table = self.define_table(tablename, *fields.values(), **other_kwargs)
135 for name, typed_field in typedfields.items():
136 field = fields[name]
137 typed_field.bind(field, table)
139 cls.__set_internals__(db=self, table=table)
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
145 def __call__(self, *_args: typing.Any, **kwargs: typing.Any) -> "TypedSet":
146 """
147 A db instance can be called directly to perform a query.
149 Usually, only a query is passed.
151 Example:
152 db(query).select()
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.")
161 if issubclass(type(cls), type) and issubclass(cls, TypedTable):
162 # table defined without @db.define decorator!
163 args[0] = cls.id != None
165 _set = super().__call__(*args, **kwargs)
166 return typing.cast(TypedSet, _set)
168 # todo: insert etc shadowen?
170 @classmethod
171 def _build_field(cls, name: str, _type: str, **kw: typing.Any) -> Field:
172 return Field(name, _type, **{**cls.default_kwargs, **kw})
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)
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
203 # typing.Optional[type] == type | None
205 match typing.get_args(ftype):
206 case (_child_type, _Types.NONETYPE):
207 # good union of Nullable
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
218 @classmethod
219 def _to_field(cls, fname: str, ftype: type, **kw: typing.Any) -> Field:
220 """
221 Convert a annotation into a pydal Field.
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
229 -> pydal.Field(fname, ftype, **kw)
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)
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)}")
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("_")
252class TypedTableMeta(type):
253 """
254 Meta class allows getattribute on class variables instead instance variables.
256 Used in `class TypedTable(Table, metaclass=TypedTableMeta)`
257 """
259 def __getattr__(self, key: str) -> Field:
260 """
261 The getattr method is only called when getattribute can't find something.
263 `__get_table_column__` is defined in `TypedTable`
264 """
265 return self.__get_table_column__(key)
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 """
273 id: int # noqa: 'id' has to be id since that's the db column
275 # set up by db.define:
276 __db: TypeDAL | None = None
277 __table: Table | None = None
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
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.
292 Example:
293 SomeTypedTable.col -> db.table.col (via TypedTableMeta.__getattr__)
295 """
296 #
297 if cls.__table:
298 return cls.__table[col]
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.
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)
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.
318 cls.__table functions as 'self'
320 Args:
321 **fields: anything you want to insert in the database
323 Returns: the ID of the new row.
325 """
326 if not cls.__table:
327 raise EnvironmentError("@define or db.define is not called on this class yet!")
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)
334# backwards compat:
335TypedRow = TypedTable
338class TypedFieldType(Field): # type: ignore
339 """
340 Typed version of pydal.Field, which will be converted to a normal Field in the background.
341 """
343 # todo: .bind
345 # will be set by .bind on db.define
346 name = ""
347 _db = None
348 _rname = None
349 _table = None
351 _type: T_annotation
352 kwargs: typing.Any
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
361 def __str__(self) -> str:
362 """
363 String representation of a Typed Field.
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}"
382 def __repr__(self) -> str:
383 """
384 More detailed string representation of a Typed Field.
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}>"
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)
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)
409 # def __eq__(self, value):
410 # return Query(self.db, self._dialect.eq, self, value)
413S = typing.TypeVar("S")
416class TypedRows(typing.Collection[S], Rows): # type: ignore
417 """
418 Can be used as the return type of a .select().
420 Example:
421 people: TypedRows[Person] = db(Person).select()
422 """
425T_Table = typing.TypeVar("T_Table", bound=Table)
428class TypedSet(pydal.objects.Set): # type: ignore # pragma: no cover
429 """
430 Used to make pydal Set more typed.
432 This class is not actually used, only 'cast' by TypeDAL.__call__
433 """
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)
442 def select(self, *fields: typing.Any, **attributes: typing.Any) -> TypedRows[T_Table]:
443 """
444 Select returns a TypedRows of a user defined table.
446 Example:
447 result: TypedRows[MyTable] = db(MyTable.id > 0).select()
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)