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
« 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
10import pydal
11from pydal._globals import DEFAULT
12from pydal.objects import Field, Query, Row, Rows, Table
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"]]
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}
32class _Types:
33 """
34 Internal type storage for stuff that mypy otherwise won't understand.
35 """
37 NONETYPE = type(None)
40# the input and output of TypeDAL.define
41T = typing.TypeVar("T", typing.Type["TypedTable"], typing.Type["Table"])
44def is_union(some_type: type) -> bool:
45 """
46 Check if a type is some type of Union.
48 Args:
49 some_type: types.UnionType = type(int | str); typing.Union = typing.Union[int, str]
51 """
52 return typing.get_origin(some_type) in (types.UnionType, typing.Union)
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__))
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.
67 It also flattens the ChainMap to a regular dict.
68 """
69 if _except is None:
70 _except = set()
72 _all = _all_annotations(cls)
73 return {k: v for k, v in _all.items() if k not in _except}
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 """
81 dal: Table
83 default_kwargs: typing.ClassVar[typing.Dict[str, typing.Any]] = {
84 # fields are 'required' (notnull) by default:
85 "notnull": True,
86 }
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.
93 Args:
94 cls:
96 Example:
97 @db.define
98 class Person(TypedTable):
99 ...
101 class Article(TypedTable):
102 ...
104 # at a later time:
105 db.define(Article)
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)
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.
120 # non-annotated variables have to be passed to define_table as kwargs
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("_")}
130 typedfields = {k: v for k, v in annotations.items() if isinstance(v, TypedFieldType)}
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("_")}
135 table: Table = self.define_table(tablename, *fields.values(), **other_kwargs)
137 for name, typed_field in typedfields.items():
138 field = fields[name]
139 typed_field.bind(field, table)
141 cls.__set_internals__(db=self, table=table)
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
147 def __call__(self, *_args: T_Query, **kwargs: typing.Any) -> "TypedSet":
148 """
149 A db instance can be called directly to perform a query.
151 Usually, only a query is passed.
153 Example:
154 db(query).select()
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.")
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
168 _set = super().__call__(*args, **kwargs)
169 return typing.cast(TypedSet, _set)
171 # todo: insert etc shadowen?
173 @classmethod
174 def _build_field(cls, name: str, _type: str, **kw: typing.Any) -> Field:
175 return Field(name, _type, **{**cls.default_kwargs, **kw})
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)
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
206 # typing.Optional[type] == type | None
208 match typing.get_args(ftype):
209 case (_child_type, _Types.NONETYPE):
210 # good union of Nullable
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
221 @classmethod
222 def _to_field(cls, fname: str, ftype: type, **kw: typing.Any) -> Field:
223 """
224 Convert a annotation into a pydal Field.
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
232 -> pydal.Field(fname, ftype, **kw)
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)
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)}")
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("_")
255class TypedTableMeta(type):
256 """
257 Meta class allows getattribute on class variables instead instance variables.
259 Used in `class TypedTable(Table, metaclass=TypedTableMeta)`
260 """
262 def __getattr__(self, key: str) -> Field:
263 """
264 The getattr method is only called when getattribute can't find something.
266 `__get_table_column__` is defined in `TypedTable`
267 """
268 return self.__get_table_column__(key)
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 """
276 id: int # noqa: 'id' has to be id since that's the db column
278 # set up by db.define:
279 __db: TypeDAL | None = None
280 __table: Table | None = None
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
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.
295 Example:
296 SomeTypedTable.col -> db.table.col (via TypedTableMeta.__getattr__)
298 """
299 #
300 if cls.__table:
301 return cls.__table[col]
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.
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)
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.
321 cls.__table functions as 'self'
323 Args:
324 **fields: anything you want to insert in the database
326 Returns: the ID of the new row.
328 """
329 if not cls.__table:
330 raise EnvironmentError("@define or db.define is not called on this class yet!")
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)
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)
348# backwards compat:
349TypedRow = TypedTable
352class TypedFieldType(Field): # type: ignore
353 """
354 Typed version of pydal.Field, which will be converted to a normal Field in the background.
355 """
357 # todo: .bind
359 # will be set by .bind on db.define
360 name = ""
361 _db = None
362 _rname = None
363 _table = None
365 _type: T_annotation
366 kwargs: typing.Any
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
375 def __str__(self) -> str:
376 """
377 String representation of a Typed Field.
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}"
396 def __repr__(self) -> str:
397 """
398 More detailed string representation of a Typed Field.
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}>"
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)
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)
423 # def __eq__(self, value):
424 # return Query(self.db, self._dialect.eq, self, value)
427S = typing.TypeVar("S")
430class TypedRows(typing.Collection[S], Rows): # type: ignore
431 """
432 Can be used as the return type of a .select().
434 Example:
435 people: TypedRows[Person] = db(Person).select()
436 """
439T_Table = typing.TypeVar("T_Table", bound=Table)
442class TypedSet(pydal.objects.Set): # type: ignore # pragma: no cover
443 """
444 Used to make pydal Set more typed.
446 This class is not actually used, only 'cast' by TypeDAL.__call__
447 """
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)
456 def select(self, *fields: typing.Any, **attributes: typing.Any) -> TypedRows[T_Table]:
457 """
458 Select returns a TypedRows of a user defined table.
460 Example:
461 result: TypedRows[MyTable] = db(MyTable.id > 0).select()
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)