Source code for loom.core.model.base
import sys
import typing
from typing import TYPE_CHECKING, Any, ClassVar
import msgspec
from msgspec import UNSET, UnsetType
from loom.core.model.field import ColumnFieldSpec
from loom.core.model.projection import Projection
from loom.core.model.relation import Relation
if TYPE_CHECKING:
class _StructMeta(type):
pass
else:
_StructMeta = type(msgspec.Struct)
def _resolve_annotation(annotation: Any, namespace: dict[str, Any]) -> Any:
"""Resolve a stringified annotation to its actual type."""
if isinstance(annotation, str):
eval_ns: dict[str, Any] = {}
eval_ns.update(namespace)
eval_ns.setdefault("typing", typing)
return typing.ForwardRef(annotation)._evaluate(eval_ns, eval_ns, frozenset[str]())
return annotation
def _build_eval_namespace(namespace: dict[str, Any]) -> dict[str, Any]:
"""Build annotation evaluation namespace from module globals + class locals."""
eval_ns: dict[str, Any] = {}
module = namespace.get("__module__")
if module:
mod = sys.modules.get(module)
if mod:
eval_ns.update(vars(mod))
eval_ns.update(namespace)
return eval_ns
def _resolve_column_default(spec: ColumnFieldSpec) -> Any:
"""Resolve runtime default assigned to a model field in class namespace."""
if spec.field.default is not msgspec.UNSET:
return spec.field.default
if spec.field.primary_key and spec.field.autoincrement:
# Avoid propagating msgspec.UNSET into INSERT bind parameters.
return None
return UNSET
def _mark_optional_unset(
attr_name: str,
annotations: dict[str, Any],
eval_ns: dict[str, Any],
) -> None:
"""Widen annotation to include ``UnsetType`` for lazy-loaded fields."""
if attr_name not in annotations:
return
resolved = _resolve_annotation(annotations[attr_name], eval_ns)
annotations[attr_name] = resolved | UnsetType
[docs]
class LoomStructMeta(_StructMeta):
"""Metaclass that intercepts ``Relation`` and ``Projection`` assignments
before ``StructMeta`` processes the class body.
Each intercepted attribute is replaced with ``UNSET`` as its default,
and the type annotation is widened to ``T | UnsetType`` so that
``omit_defaults=True`` strips unloaded fields from serialisation output.
"""
def __new__(
cls,
name: str,
bases: tuple[type, ...],
namespace: dict[str, Any],
**kwargs: Any,
) -> Any:
kwargs.setdefault("frozen", True)
kwargs.setdefault("kw_only", True)
kwargs.setdefault("omit_defaults", True)
kwargs.setdefault("rename", "camel")
columns: dict[str, ColumnFieldSpec] = {}
relations: dict[str, Relation] = {}
projections: dict[str, Projection] = {}
annotations: dict[str, Any] = namespace.get("__annotations__", {})
eval_ns = _build_eval_namespace(namespace)
for attr_name in list(namespace):
value = namespace[attr_name]
if isinstance(value, ColumnFieldSpec):
columns[attr_name] = value
namespace[attr_name] = _resolve_column_default(value)
continue
if isinstance(value, Relation):
relations[attr_name] = value
namespace[attr_name] = UNSET
_mark_optional_unset(attr_name, annotations, eval_ns)
continue
if isinstance(value, Projection):
projections[attr_name] = value
namespace[attr_name] = UNSET
_mark_optional_unset(attr_name, annotations, eval_ns)
namespace["__annotations__"] = annotations
struct_cls: Any = super().__new__(cls, name, bases, namespace, **kwargs)
struct_cls.__loom_columns__ = columns
struct_cls.__loom_relations__ = relations
struct_cls.__loom_projections__ = projections
return struct_cls
if TYPE_CHECKING:
class BaseModel(msgspec.Struct):
"""Typing-only base model to avoid metaclass noise in mypy."""
__tablename__: ClassVar[str]
__loom_columns__: ClassVar[dict[str, ColumnFieldSpec]]
else:
[docs]
class BaseModel(msgspec.Struct, metaclass=LoomStructMeta):
"""Base for all loom domain models.
Subclasses must declare ``__tablename__`` and provide typed attributes.
Column metadata is optional via ``ColumnField(...)``.
"""
__tablename__: ClassVar[str]