From a009d5c41132954f6ebc36e5c2af02b8a46572c6 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 17:06:30 -0700 Subject: [PATCH 1/9] feat: add typed ORM fields and blocking basedpyright CI --- .github/workflows/lint.yml | 11 +-- fastapi_startkit/pyproject.toml | 4 +- .../masoniteorm/models/caster.py | 30 +++++-- .../masoniteorm/models/fields.py | 84 +++++++++++++++---- .../masoniteorm/models/model.py | 16 +++- .../tests/masoniteorm/fixtures/model.py | 12 +-- .../tests/masoniteorm/models/test_model.py | 10 +++ .../models/test_model_attributes.py | 18 ++++ fastapi_startkit/uv.lock | 46 +++++----- 9 files changed, 171 insertions(+), 60 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3689eeba..568c6bbd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 @@ -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 diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 6358cf7b..23431dd0 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -107,7 +107,7 @@ dev = [ "faker>=40.13.0", "langchain>=1.0.0", "langchain-core>=1.0.0", - "pyright>=1.1.411", + "basedpyright>=1.31.4", ] @@ -121,7 +121,7 @@ fixable = ["F401"] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] -[tool.pyright] +[tool.basedpyright] include = ["src/fastapi_startkit"] exclude = [ "**/tests", diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/caster.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/caster.py index 7f2172d9..fe970781 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/caster.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/caster.py @@ -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: @@ -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()) @@ -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 diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/fields.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/fields.py index 4140dc6b..40ba529c 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/fields.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/fields.py @@ -1,6 +1,8 @@ -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, @@ -8,49 +10,99 @@ ) -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) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py index 39cfbbce..510e131a 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Self, dataclass_transform import inflection import pendulum @@ -9,7 +9,7 @@ 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 @@ -18,6 +18,7 @@ from fastapi_startkit.masoniteorm.models.builder import QueryBuilder +@dataclass_transform(field_specifiers=(Field, ModelField)) class Model(Attribute, Relationship, ObservesEvents): db_manager: "DatabaseManager" = None __table__ = None @@ -34,9 +35,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, ) diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/model.py b/fastapi_startkit/tests/masoniteorm/fixtures/model.py index 5abbc181..c746b402 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/model.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/model.py @@ -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, @@ -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") diff --git a/fastapi_startkit/tests/masoniteorm/models/test_model.py b/fastapi_startkit/tests/masoniteorm/models/test_model.py index 88ebdbc6..6ddea25d 100644 --- a/fastapi_startkit/tests/masoniteorm/models/test_model.py +++ b/fastapi_startkit/tests/masoniteorm/models/test_model.py @@ -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 @@ -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" diff --git a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py index b3957105..448bda04 100644 --- a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py +++ b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py @@ -24,6 +24,24 @@ } +def test_deprecated_model_field_remains_compatible(): + from fastapi_startkit.masoniteorm import ModelField + from tests.masoniteorm.fixtures.casts import Address + + with pytest.warns(DeprecationWarning, match="use Field"): + class LegacyUser(Model): + address: Address = ModelField() + + user = LegacyUser(address={"city": "Sydney"}) + assert isinstance(user.address, Address) + assert user.address.city == "Sydney" + assert isinstance(LegacyUser.address, ModelField) + user.address = Address(city="Melbourne") + assert user.address.city == "Melbourne" + restored = LegacyUser(user.get_attributes()) + assert restored.address.city == "Melbourne" + + @pytest.fixture async def db(): manager = DatabaseManager(ConnectionFactory(), SQLITE_CONFIG) diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index c47982b9..67171c09 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -99,6 +99,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "basedpyright" +version = "1.40.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ee/8d0b6806338b13526303cf72754351221a32cdb80ab56dcf2c91d6b1ea57/basedpyright-1.40.1.tar.gz", hash = "sha256:da1c9913b6d169340a0dbb6df76ea97f476ccb697da8f92659ac46032f6d2ce8", size = 25131254, upload-time = "2026-09-10T23:17:24.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/84/c1e1e845d0453253a98d1ba188d68597f0ba7e552eeabea514d9395d418a/basedpyright-1.40.1-py3-none-any.whl", hash = "sha256:222dc0382caf9816eb23a27cb7059fa96356ddc500b3b7b306072848fd650244", size = 13689276, upload-time = "2026-09-10T23:17:20.322Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -527,7 +539,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.51.0" +version = "0.56.0" source = { editable = "." } dependencies = [ { name = "cleo" }, @@ -575,13 +587,13 @@ dev = [ { name = "aiomysql" }, { name = "aiosqlite" }, { name = "asyncpg" }, + { name = "basedpyright" }, { name = "dumpdie" }, { name = "faker" }, { name = "fastapi", extra = ["standard"] }, { name = "itsdangerous" }, { name = "langchain" }, { name = "langchain-core" }, - { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -620,13 +632,13 @@ dev = [ { name = "aiomysql", specifier = ">=0.2.0" }, { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "asyncpg", specifier = ">=0.29.0" }, + { name = "basedpyright", specifier = ">=1.31.4" }, { name = "dumpdie", specifier = ">=1.5.0" }, { name = "faker", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.124.4" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "langchain", specifier = ">=1.0.0" }, { name = "langchain-core", specifier = ">=1.0.0" }, - { name = "pyright", specifier = ">=1.1.411" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, @@ -1214,12 +1226,19 @@ wheels = [ ] [[package]] -name = "nodeenv" -version = "1.10.0" +name = "nodejs-wheel-binaries" +version = "24.19.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/76/7e97195e14346598565a0de4ca8bdbd5e634b3fb5b1ba590b7b1b89f8a63/nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b", size = 8058, upload-time = "2026-08-19T21:47:19.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/0774b52c7be8151ad9d5aff44edc100c3f13d6d8eb3765f63ffa40e69fe8/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c", size = 56047203, upload-time = "2026-08-19T21:46:43.448Z" }, + { url = "https://files.pythonhosted.org/packages/67/3a/4fdbbfecf2c23d52c0e3f68de7f7c1b3c97a26d328c69c5f6c49c48e340e/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514", size = 56219459, upload-time = "2026-08-19T21:46:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/0147149415195c59b8a72a594916bfb80d6be4d586f9fbfda313889e0efc/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227", size = 60588256, upload-time = "2026-08-19T21:46:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/89/6631d0982353da1bb7bc00bb1988f702822c62b42634f57999ee53b5c337/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8", size = 61123607, upload-time = "2026-08-19T21:46:58.117Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/fa30f0841e4602995782e124359f9b910c7b481d98decf61ef0b2fc3ebfb/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7", size = 62632842, upload-time = "2026-08-19T21:47:03.42Z" }, + { url = "https://files.pythonhosted.org/packages/18/01/22d97ca72213f66cc386ee638db30c2e62757fdf761c6029074bced83d1c/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c", size = 63250653, upload-time = "2026-08-19T21:47:07.938Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/e3be8fa327a795bcaf7a19cd84299e338a7bce32ff0665fdce9cfa22573c/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd", size = 42448503, upload-time = "2026-08-19T21:47:12.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/34cf28ba1691a060174948a9927fe61091982d6048b2e403071a9acce443/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36", size = 40090155, upload-time = "2026-08-19T21:47:16.032Z" }, ] [[package]] @@ -1497,19 +1516,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" }, ] -[[package]] -name = "pyright" -version = "1.1.411" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, -] - [[package]] name = "pytest" version = "9.0.3" From e1b045ac6c0a8772091eb3afb8179ce751ad76ac Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 17:07:23 -0700 Subject: [PATCH 2/9] docs: remove local changelog in favor of GitHub releases --- fastapi_startkit/CHANGELOG.md | 41 ----------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 fastapi_startkit/CHANGELOG.md diff --git a/fastapi_startkit/CHANGELOG.md b/fastapi_startkit/CHANGELOG.md deleted file mode 100644 index 81740b90..00000000 --- a/fastapi_startkit/CHANGELOG.md +++ /dev/null @@ -1,41 +0,0 @@ -# Changelog - -## Unreleased - -### `serve` command defaults now come from `FastAPIConfig` - -`ServeCommand` no longer restates defaults that `FastAPIConfig` already declares. -Every server setting resolves as **CLI flag > `fastapi` config > `FastAPIConfig` default**. - -- **New `FastAPIConfig.app`** field (`"bootstrap.application:app"`), so the served - entrypoint is configurable like every other setting. Add it to your - `config/fastapi.py` if you want to override it: - - ```python - app: str = "bootstrap.application:app" - ``` - -- **Behaviour change:** an application that registers no `fastapi` config now gets - `reload_excludes` (`["*.log", "tests/*", "node_modules/*"]`) passed to uvicorn when - reload is on. Previously these were only forwarded when a config was registered, so - such an app watched excluded paths. Set `reload_excludes = []` in your `fastapi` - config to restore the old behaviour. - -- A `fastapi` config key that is present but set to `None` now falls back to the - `FastAPIConfig` default instead of being forwarded as `None`. - -## 0.48.0 - -### Breaking changes - -The top-level `fastapi_startkit.providers` package has been removed. Its -contents moved into `foundation` and `support`, and there is **no** -backward-compatibility shim — consumers must update their imports: - -| Old import | New import | -|---|---| -| `from fastapi_startkit.providers import Provider` | `from fastapi_startkit.support import Provider` | -| `from fastapi_startkit.providers.app_provider import ...` | `from fastapi_startkit.foundation.app_provider import ...` | -| `from fastapi_startkit.helpers.dataclass import ...` | `from fastapi_startkit.support.dataclass import ...` | - -`fastapi_startkit.helpers.app` has been removed. From abe038af452d50f996689a2fb13f40118b8c7407 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 17:11:09 -0700 Subject: [PATCH 3/9] style: fix Ruff formatting for ORM changes --- .../src/fastapi_startkit/masoniteorm/models/model.py | 8 +++++++- .../tests/masoniteorm/models/test_model_attributes.py | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py index 510e131a..544fe198 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py @@ -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, Field, FieldDescriptor, ModelField, 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 diff --git a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py index 448bda04..d0cf7f1d 100644 --- a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py +++ b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py @@ -29,6 +29,7 @@ def test_deprecated_model_field_remains_compatible(): from tests.masoniteorm.fixtures.casts import Address with pytest.warns(DeprecationWarning, match="use Field"): + class LegacyUser(Model): address: Address = ModelField() From 1115d0a252a87b16c392d4255a9394139167982a Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 17:12:10 -0700 Subject: [PATCH 4/9] docs: explain typed fields and ModelField deprecation --- fastapi_startkit/README.md | 55 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/fastapi_startkit/README.md b/fastapi_startkit/README.md index 3cfefb66..bbfb8a72 100644 --- a/fastapi_startkit/README.md +++ b/fastapi_startkit/README.md @@ -37,6 +37,51 @@ fastapi-startkit[vite] # Jinja2 for Vite integration Full documentation is available at [fastapi-startkit.github.io](https://fastapi-startkit.github.io). +### Typed ORM fields + +Declare fields with `Field[T]()` to specify their Python type. A concrete default +can supply the type, as in `Field(default=False)`: + +```python +from pydantic import BaseModel + +from fastapi_startkit.masoniteorm import Field, Model + + +class Address(BaseModel): + city: str + + +class User(Model): + id = Field[int]() + name = Field[str]() + email = Field[str]() + is_admin = Field(default=False) + address = Field[Address]() +``` + +Instance attributes expose the declared types, and the ORM uses those types for +runtime casting. Nested Pydantic models such as `Address` are serialized to JSON +and reconstructed when read. Descriptor fields participate in `fill()` and +`update()` just like annotated fields. Existing annotated declarations remain +supported, and the base `Model` registers `Field` with `dataclass_transform` for +static analysis. This decorator does not generate a runtime constructor or +validate that every required field was supplied. + +`ModelField` remains defined and publicly importable for compatibility: + +```python +from fastapi_startkit.masoniteorm import ModelField + + +class LegacyUser(Model): + address: Address = ModelField() +``` + +Constructing `ModelField()` emits a `DeprecationWarning`. It is scheduled for +removal in **2.x**; migrate `address: Address = ModelField()` to +`address = Field[Address]()`. + ## Development ```bash @@ -48,11 +93,21 @@ uv run pytest tests/ -v # Run tests with coverage uv run pytest --cov --cov-report=term-missing + +# Check lint, formatting, and types +uv run ruff check . +uv run ruff format --check . +uv run basedpyright ``` Coverage is collected in CI and reported to [Codecov](https://codecov.io/gh/fastapi-startkit/fastapi-startkit-framework). +Ruff and basedpyright failures fail their CI jobs. Basedpyright checks +`src/fastapi_startkit` in standard mode, without a baseline; existing type errors +must be addressed for that job to pass. Release notes are maintained in +[GitHub Releases](https://github.com/fastapi-startkit/fastapi-startkit-framework/releases). + ## License See the repository root for license details. From da7538c309a0cf6d38fb3d41c0b9dec00d369f0e Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 17:15:03 -0700 Subject: [PATCH 5/9] docs: keep field guidance in documentation site --- fastapi_startkit/README.md | 55 -------------------------------------- 1 file changed, 55 deletions(-) diff --git a/fastapi_startkit/README.md b/fastapi_startkit/README.md index bbfb8a72..3cfefb66 100644 --- a/fastapi_startkit/README.md +++ b/fastapi_startkit/README.md @@ -37,51 +37,6 @@ fastapi-startkit[vite] # Jinja2 for Vite integration Full documentation is available at [fastapi-startkit.github.io](https://fastapi-startkit.github.io). -### Typed ORM fields - -Declare fields with `Field[T]()` to specify their Python type. A concrete default -can supply the type, as in `Field(default=False)`: - -```python -from pydantic import BaseModel - -from fastapi_startkit.masoniteorm import Field, Model - - -class Address(BaseModel): - city: str - - -class User(Model): - id = Field[int]() - name = Field[str]() - email = Field[str]() - is_admin = Field(default=False) - address = Field[Address]() -``` - -Instance attributes expose the declared types, and the ORM uses those types for -runtime casting. Nested Pydantic models such as `Address` are serialized to JSON -and reconstructed when read. Descriptor fields participate in `fill()` and -`update()` just like annotated fields. Existing annotated declarations remain -supported, and the base `Model` registers `Field` with `dataclass_transform` for -static analysis. This decorator does not generate a runtime constructor or -validate that every required field was supplied. - -`ModelField` remains defined and publicly importable for compatibility: - -```python -from fastapi_startkit.masoniteorm import ModelField - - -class LegacyUser(Model): - address: Address = ModelField() -``` - -Constructing `ModelField()` emits a `DeprecationWarning`. It is scheduled for -removal in **2.x**; migrate `address: Address = ModelField()` to -`address = Field[Address]()`. - ## Development ```bash @@ -93,21 +48,11 @@ uv run pytest tests/ -v # Run tests with coverage uv run pytest --cov --cov-report=term-missing - -# Check lint, formatting, and types -uv run ruff check . -uv run ruff format --check . -uv run basedpyright ``` Coverage is collected in CI and reported to [Codecov](https://codecov.io/gh/fastapi-startkit/fastapi-startkit-framework). -Ruff and basedpyright failures fail their CI jobs. Basedpyright checks -`src/fastapi_startkit` in standard mode, without a baseline; existing type errors -must be addressed for that job to pass. Release notes are maintained in -[GitHub Releases](https://github.com/fastapi-startkit/fastapi-startkit-framework/releases). - ## License See the repository root for license details. From 866cd98429fd68735bc186cbd9dc46da0a02d117 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 17:16:34 -0700 Subject: [PATCH 6/9] fix: export Field and ModelField from models package --- .../src/fastapi_startkit/masoniteorm/models/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/__init__.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/__init__.py index 7c7cf005..c7e892d0 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/__init__.py @@ -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"] From 195f1e2af76371ace6bbf0dc5871c1b3fea45839 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 17:19:35 -0700 Subject: [PATCH 7/9] test: preserve annotated and mixed ORM field declarations --- .../models/test_model_attributes.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py index d0cf7f1d..b4a62788 100644 --- a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py +++ b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py @@ -43,6 +43,48 @@ class LegacyUser(Model): assert restored.address.city == "Melbourne" +@pytest.mark.parametrize("mixed_fields", [False, True], ids=["annotation-only", "mixed-fields"]) +def test_annotation_only_columns_remain_supported(mixed_fields): + from fastapi_startkit.masoniteorm import Field + + if mixed_fields: + + class CompatibleUser(Model): + id: int + name: str + email: str + score = Field[int]() + is_admin = Field(default=False) + + else: + + class CompatibleUser(Model): + id: int + name: str + email: str + + # Hydration from raw storage still uses plain annotations for casting. + user = CompatibleUser({"id": "42", "name": "Alex", "email": "alex@example.com", "score": "7"}) + assert user.id == 42 + assert isinstance(user.id, int) + assert user.name == "Alex" + assert user.email == "alex@example.com" + + user.name = "Jane" + user.fill({"email": "jane@example.com"}) + assert user.name == "Jane" + assert user.email == "jane@example.com" + assert {"id", "name", "email"} <= set(CompatibleUser.__fillable__) + + if mixed_fields: + assert user.score == 7 + assert isinstance(user.score, int) + assert user.is_admin is False + user.fill({"score": 9, "is_admin": True}) + assert user.score == 9 + assert user.is_admin is True + + @pytest.fixture async def db(): manager = DatabaseManager(ConnectionFactory(), SQLITE_CONFIG) From 2c681033f4265dc950dc26334d23ce4abc683cb7 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 17:28:04 -0700 Subject: [PATCH 8/9] test: cover field descriptor setters and fix timestamp assignment --- .../masoniteorm/models/fields.py | 2 +- .../models/test_model_attributes.py | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/fields.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/fields.py index 40ba529c..4c1905ac 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/fields.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/fields.py @@ -157,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) diff --git a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py index b4a62788..58da9f3f 100644 --- a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py +++ b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py @@ -85,6 +85,50 @@ class CompatibleUser(Model): assert user.is_admin is True +def test_field_descriptor_assignment_casts_and_tracks_dirty_values(): + from fastapi_startkit.masoniteorm import Field + + class DescriptorUser(Model): + id = Field[int]() + + user = DescriptorUser({"id": 1}) + user.sync_original() + # Bypass Model.__setattr__ to exercise Python's descriptor protocol. + object.__setattr__(user, "id", "42") + assert user.id == 42 + assert user.get_dirty() == {"id": 42} + assert user._original["id"] == 1 + + +def test_legacy_model_field_descriptor_assignment_serializes_pydantic_values(): + import json + + from fastapi_startkit.masoniteorm import ModelField + from tests.masoniteorm.fixtures.casts import Address + + with pytest.warns(DeprecationWarning, match="removed in 2.x"): + + class DescriptorLegacyUser(Model): + address: Address = ModelField() + + user = DescriptorLegacyUser() + object.__setattr__(user, "address", Address(city="Sydney")) + assert isinstance(user.address, Address) + assert user.address.city == "Sydney" + assert json.loads(user.get_dirty()["address"])["city"] == "Sydney" + + +def test_updated_at_descriptor_assignment_uses_attribute_storage(): + class TimestampUser(Model): + pass + + user = TimestampUser() + timestamp = pendulum.datetime(2026, 1, 2, 3, 4, 5, tz="UTC") + object.__setattr__(user, "updated_at", timestamp) + assert user.updated_at == timestamp + assert user.get_dirty()["updated_at"] == "2026-01-02 03:04:05" + + @pytest.fixture async def db(): manager = DatabaseManager(ConnectionFactory(), SQLITE_CONFIG) From 1738113cf52a4fb7a9fe013662022db71ae05917 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 18:00:48 -0700 Subject: [PATCH 9/9] fix(tests): import structures helpers from their real module tests/utils/test_structures.py imported `fastapi_startkit.utils.structures`, a module that does not exist, so collection aborted with ModuleNotFoundError and took the whole suite down with it. The helpers `load`, `data`, `data_get` and `data_set` live in `fastapi_startkit.support.structures`. Co-Authored-By: Claude Opus 5 (1M context) --- fastapi_startkit/tests/utils/test_structures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi_startkit/tests/utils/test_structures.py b/fastapi_startkit/tests/utils/test_structures.py index 12b1e9f2..6a460ae6 100644 --- a/fastapi_startkit/tests/utils/test_structures.py +++ b/fastapi_startkit/tests/utils/test_structures.py @@ -9,7 +9,7 @@ from dotty_dict import Dotty from fastapi_startkit.exceptions.exceptions import LoaderNotFound -from fastapi_startkit.utils.structures import data, data_get, data_set, load +from fastapi_startkit.support.structures import data, data_get, data_set, load MODULE_SOURCE = "VALUE = 42\n\n\ndef greet():\n return 'hi'\n"