Coverage for src/typedal/core.py: 67%
124 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-22 10:11 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-22 10:11 +0200
1"""
2Core functionality of TypeDAL.
3"""
5import datetime as dt
6import types
7import typing
8from collections import ChainMap
9from decimal import Decimal
11import pydal
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
17BASIC_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}
31class _Types:
32 """
33 Internal type storage for stuff that mypy otherwise won't understand.
34 """
36 NONETYPE = type(None)
39# the input and output of TypeDAL.define
40T = typing.TypeVar("T", typing.Type["TypedTable"], typing.Type["Table"])
43def is_union(some_type: type) -> bool:
44 """
45 Check if a type is some type of Union.
47 Args:
48 some_type: types.UnionType = type(int | str); typing.Union = typing.Union[int, str]
50 """
51 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}
73class 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 """
78 dal: Table
80 default_kwargs = {
81 # fields are 'required' (notnull) by default:
82 "notnull": True,
83 }
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.
90 Args:
91 cls:
93 Example:
94 @db.define
95 class Person(TypedTable):
96 ...
98 class Article(TypedTable):
99 ...
101 # at a later time:
102 db.define(Article)
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)
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.
117 # non-annotated variables have to be passed to define_table as kwargs
119 tablename = self._to_snake(cls.__name__)
120 annotations = all_annotations(cls)
122 raise ValueError(annotations, cls.__dict__)
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("_")}
127 table: Table = self.define_table(tablename, *fields, **other_kwargs)
129 cls.__set_internals__(db=self, table=table)
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
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.
139 Usually, only a query is passed
141 Example:
142 db(query).select()
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
152 return super().__call__(*args, **kwargs)
154 # todo: insert etc shadowen?
156 @classmethod
157 def _build_field(cls, name: str, _type: str, **kw: typing.Any) -> Field:
158 return Field(name, _type, **{**cls.default_kwargs, **kw})
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)
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
189 # typing.Optional[type] == type | None
191 match typing.get_args(ftype):
192 case (_child_type, _Types.NONETYPE):
193 # good union of Nullable
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
204 @classmethod
205 def _to_field(cls, fname: str, ftype: type, **kw: typing.Any) -> Field:
206 """
207 Convert a annotation into a pydal Field.
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
215 -> pydal.Field(fname, ftype, **kw)
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)
227 converted_type = cls._annotation_to_pydal_fieldtype(ftype, kw)
228 if not converted_type:
229 raise NotImplementedError(f"Unsupported type {ftype}/{type(ftype)}")
231 return cls._build_field(fname, converted_type, **kw)
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("_")
239class TypedTableMeta(type):
240 """
241 Meta class allows getattribute on class variables instead instance variables.
243 Used in `class TypedTable(Table, metaclass=TypedTableMeta)`
244 """
246 def __getattr__(self, key: str) -> Field:
247 """
248 The getattr method is only called when getattribute can't find something.
250 `__get_table_column__` is defined in `TypedTable`
251 """
252 return self.__get_table_column__(key)
255class 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 """
260 id: int # noqa: 'id' has to be id since that's the db column
262 # set up by db.define:
263 __db: TypeDAL | None = None
264 __table: Table | None = None
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
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.
279 Example:
280 SomeTypedTable.col -> db.table.col (via TypedTableMeta.__getattr__)
282 """
283 #
284 if cls.__table:
285 return cls.__table[col]
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.
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)
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.
305 cls.__table functions as 'self'
307 Args:
308 **fields: anything you want to insert in the database
310 Returns: the ID of the new row.
312 """
313 if not cls.__table:
314 raise EnvironmentError("@define or db.define is not called on this class yet!")
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)
321# backwards compat:
322TypedRow = TypedTable
325class TypedFieldType(Field): # type: ignore
326 """
327 Typed version of pydal.Field, which will be converted to a normal Field in the background.
328 """
330 _table = "<any table>"
331 _type: T_annotation
332 kwargs: typing.Any
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
341 def __str__(self) -> str:
342 """
343 String representation of a Typed Field.
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}"
362 def __repr__(self) -> str:
363 """
364 More detailed string representation of a Typed Field.
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}>"
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)
382S = typing.TypeVar("S")
385class TypedRows(typing.Collection[S], Rows): # type: ignore
386 """
387 Can be used as the return type of a .select().
389 Example:
390 people: TypedRows[Person] = db(Person).select()
391 """