Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ jobs:
working-directory: fastapi_startkit
run: uv run ruff format --check .

pyright:
name: Pyright (non-blocking)
basedpyright:
name: Basedpyright
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -51,9 +51,6 @@ jobs:
working-directory: fastapi_startkit
run: uv sync --group dev

# Type checking is advisory while the existing baseline is worked down;
# continue-on-error keeps a failing run from blocking the pipeline.
- name: Run pyright
continue-on-error: true
- name: Run basedpyright
working-directory: fastapi_startkit
run: uv run pyright
run: uv run basedpyright
41 changes: 0 additions & 41 deletions fastapi_startkit/CHANGELOG.md

This file was deleted.

3 changes: 2 additions & 1 deletion fastapi_startkit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ dev = [
"faker>=40.13.0",
"langchain>=1.0.0",
"langchain-core>=1.0.0",
"basedpyright>=1.31.4",
"pyright>=1.1.411",
"pytest-benchmark>=5.2.3",
]
Expand All @@ -122,7 +123,7 @@ fixable = ["F401"]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]

[tool.pyright]
[tool.basedpyright]
include = ["src/fastapi_startkit"]
exclude = [
"**/tests",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from .model import Model
from .caster import Caster
from .registry import Registry
from .fields import Field, ModelField

__all__ = ["Model", "Caster", "Registry", "Field", "ModelField"]
30 changes: 25 additions & 5 deletions fastapi_startkit/src/fastapi_startkit/masoniteorm/models/caster.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
from decimal import Decimal
from enum import Enum
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, get_type_hints, Optional
from typing import TYPE_CHECKING, Any, get_args, get_type_hints, Optional
from pydantic.fields import FieldInfo
from pydantic import BaseModel as PydanticModel
from fastapi_startkit.carbon import Carbon

if TYPE_CHECKING:
Expand Down Expand Up @@ -214,7 +215,7 @@ def build_casts(cls, model):

# Ignore the builder
annotations = {k: v for k, v in annotations.items() if k not in cls.IGNORE_CASTS}
from .fields import ModelField, FieldDescriptor
from .fields import FieldDescriptor, ModelField

# 1. Collect all potential fields (annotations + descriptors)
all_field_names = set(annotations.keys())
Expand All @@ -226,11 +227,30 @@ def build_casts(cls, model):

casts = {}
for field_name in all_field_names:
typ = annotations.get(field_name) or "str"
descriptor = descriptors.get(field_name, None)
typ = annotations.get(field_name)

# AttributeField: use the type annotation as the model class
if isinstance(descriptor, ModelField):
# ``Field[int]()`` carries its runtime type in ``__orig_class__``.
# This lets models use typed descriptors without repeating an
# annotation solely for the casting layer.
if typ is None and isinstance(descriptor, FieldDescriptor):
generic_args = get_args(getattr(descriptor, "__orig_class__", None))
if generic_args:
typ = generic_args[0]

# An unsubscripted field can still derive its cast from a concrete
# default, as in ``Field(default=False)``.
if typ is None and isinstance(descriptor, FieldDescriptor):
from pydantic_core import PydanticUndefined

if descriptor.field_info.default is not PydanticUndefined:
typ = type(descriptor.field_info.default)

typ = typ or "str"

# Nested Pydantic models are stored as JSON and hydrated back into
# their declared type, e.g. ``address = Field[Address]()``.
if isinstance(descriptor, ModelField) or (isinstance(typ, type) and issubclass(typ, PydanticModel)):
casts[field_name] = ModelCast(model_class=typ)
continue

Expand Down
86 changes: 69 additions & 17 deletions fastapi_startkit/src/fastapi_startkit/masoniteorm/models/fields.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,108 @@
from pydantic.fields import FieldInfo
from typing import Any, Callable, Generic, Protocol, Self, TypeVar, overload
import warnings

from pydantic import Field as BaseField
from typing import Any
from pydantic.fields import FieldInfo

from fastapi_startkit.masoniteorm.models.observer import (
CreatedAtObserver,
UpdatedAtObserver,
)


class FieldDescriptor:
T = TypeVar("T")


class _AttributeModel(Protocol):
def get_attribute(self, key: str) -> Any: ...

def set_attribute(self, key: str, value: Any) -> None: ...


class FieldDescriptor(Generic[T]):
"""
A descriptor that wraps Pydantic's FieldInfo.
It allows us to store metadata that the Caster can later discover.
"""

def __init__(self, field_info: FieldInfo):
def __init__(self, field_info: FieldInfo) -> None:
self.field_info = field_info
self.name = None
self.name: str | None = None

def __set_name__(self, owner, name):
def __set_name__(self, owner: type[_AttributeModel], name: str) -> None:
self.name = name

def __get__(self, instance, owner):
@overload
def __get__(self, instance: None, owner: type[_AttributeModel]) -> FieldInfo: ...

@overload
def __get__(self, instance: _AttributeModel, owner: type[_AttributeModel]) -> T: ...

def __get__(self, instance: _AttributeModel | None, owner: type[_AttributeModel]) -> T | FieldInfo:
if instance is None:
# When accessed on the class (e.g., User.name), return the FieldInfo
return self.field_info

# When accessed on the instance (e.g., user.name), retrieve from ORM storage
assert self.name is not None
return instance.get_attribute(self.name)

def __set__(self, instance, value):
def __set__(self, instance: _AttributeModel, value: T) -> None:
# When setting (e.g., user.name = 'Joe'), update ORM storage
instance.set_value(self.name, value)
assert self.name is not None
instance.set_attribute(self.name, value)


def Field(*args, **kwargs) -> Any:
class Field(FieldDescriptor[T]):
"""
Factory function that returns a FieldDescriptor wrapping a Pydantic Field.
Typed ORM field descriptor backed by Pydantic field metadata.

Required fields can state their type explicitly with ``Field[int]()``.
Fields with a default infer their type with ``Field(default=False)``.
"""
return FieldDescriptor(BaseField(*args, **kwargs))

@overload
def __init__(self, *, default: T, default_factory: None = None, **kwargs: Any) -> None: ...

class ModelField:
def __set_name__(self, owner, name):
@overload
def __init__(self, *, default_factory: Callable[[], T], **kwargs: Any) -> None: ...

@overload
def __init__(self, *args: Any, **kwargs: Any) -> None: ...

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(BaseField(*args, **kwargs))


class ModelField(Generic[T]):
"""Deprecated; scheduled for removal in 2.x. Use ``Field[Address]()`` instead."""

def __init__(self, default: T | None = None) -> None:
warnings.warn(
"ModelField is deprecated and will be removed in 2.x; use Field[YourModel]() instead.",
DeprecationWarning,
stacklevel=2,
)
self.default = default
self.name: str | None = None

def __set_name__(self, owner: type[_AttributeModel], name: str) -> None:
self.name = name

def __get__(self, instance, owner):
@overload
def __get__(self, instance: None, owner: type[_AttributeModel]) -> Self: ...

@overload
def __get__(self, instance: _AttributeModel, owner: type[_AttributeModel]) -> T: ...

def __get__(self, instance: _AttributeModel | None, owner: type[_AttributeModel]) -> T | Self:
if instance is None:
return self
assert self.name is not None
return instance.get_attribute(self.name)

def __set__(self, instance, value):
def __set__(self, instance: _AttributeModel, value: T) -> None:
assert self.name is not None
instance.set_attribute(self.name, value)


Expand Down Expand Up @@ -105,4 +157,4 @@ def __get__(self, instance, owner):
return instance.get_attribute(self.name)

def __set__(self, instance, value):
instance.set_value(self.name, value)
instance.set_attribute(self.name, value)
22 changes: 18 additions & 4 deletions fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Self, overload
from typing import TYPE_CHECKING, Any, Self, dataclass_transform, overload

import inflection
import pendulum
Expand All @@ -9,7 +9,13 @@
from fastapi_startkit.masoniteorm.collection import Collection
from fastapi_startkit.masoniteorm.connections.manager import DatabaseManager
from fastapi_startkit.masoniteorm.models.attribute import Attribute
from fastapi_startkit.masoniteorm.models.fields import CreatedAtField, UpdatedAtField
from fastapi_startkit.masoniteorm.models.fields import (
CreatedAtField,
Field,
FieldDescriptor,
ModelField,
UpdatedAtField,
)
from fastapi_startkit.masoniteorm.models.registry import Registry
from fastapi_startkit.masoniteorm.models.relationship import Relationship
from fastapi_startkit.masoniteorm.observers import ObservesEvents
Expand All @@ -18,6 +24,7 @@
from fastapi_startkit.masoniteorm.models.builder import QueryBuilder, WhereGroup


@dataclass_transform(field_specifiers=(Field, ModelField))
class Model(Attribute, Relationship, ObservesEvents):
db_manager: "DatabaseManager" = None
__table__ = None
Expand All @@ -34,9 +41,16 @@ def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Registry.register(cls)

declared_fields = dict.fromkeys(
[
*cls.__annotations__,
*(name for name, value in vars(cls).items() if isinstance(value, FieldDescriptor)),
]
)

fillable = []
for name, _typ in cls.__annotations__.items():
attr = getattr(cls, name, None)
for name in declared_fields:
attr = vars(cls).get(name)
from fastapi_startkit.masoniteorm.relationships.BaseRelationship import (
BaseRelationship,
)
Expand Down
12 changes: 6 additions & 6 deletions fastapi_startkit/tests/masoniteorm/fixtures/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from fastapi_startkit.carbon.carbon import Carbon
from tests.masoniteorm.fixtures.casts import Address
from fastapi_startkit.masoniteorm import ModelField, Field
from fastapi_startkit.masoniteorm import Field
from fastapi_startkit.masoniteorm import (
HasOne,
BelongsTo,
Expand All @@ -16,16 +16,16 @@


class User(Model):
id: int
name: str
email: str
id = Field[int]()
name = Field[str]()
email = Field[str]()
email_verified_at: datetime
date_of_birth: date
session_duration: timedelta
punch_in_time: time = Field(default=time(12, 0, 0))
is_admin: bool
is_admin = Field(default=False)
preferences: dict
address: Address = ModelField()
address = Field[Address]()

profile: "Profile" = HasOne("Profile", "user_id", "id")
articles: "Articles" = HasMany("Articles", "id", "user_id")
Expand Down
10 changes: 10 additions & 0 deletions fastapi_startkit/tests/masoniteorm/models/test_model.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from fastapi_startkit.masoniteorm.models.model import Model
from fastapi_startkit.masoniteorm.models.fields import Field
from tests.masoniteorm.fixtures.model import User
from tests.masoniteorm.sqlite.test_case import TestCase

Expand Down Expand Up @@ -114,6 +115,15 @@ class Post(Model):
assert "title" in Post.__fillable__
assert "body" in Post.__fillable__

async def test_typed_fields_are_in_fillable(self):
class Post(Model):
__table__ = "posts"
title = Field[str]()
published = Field(default=False)

assert "title" in Post.__fillable__
assert "published" in Post.__fillable__

async def test_framework_fields_excluded_from_fillable(self):
class Post(Model):
__table__ = "posts"
Expand Down
Loading
Loading