fix(typing): declare ModelProtocol attributes as ClassVar so plain declarative models satisfy it#769
Conversation
ModelProtocol documents that any mapped class — including plain
sqlalchemy.orm.DeclarativeBase models — satisfies the protocol, but under
mypy that has never been true: the protocol declares
__table__/__mapper__/__name__ as instance variables, while DeclarativeBase
declares them as ClassVar (sqlalchemy/orm/decl_api.py), so every model not
built on Advanced Alchemy's own bases fails the ModelT bound:
error: Type argument "PlainModel" of "SQLAlchemySyncRepository" must be a subtype of "ModelProtocol" [type-var]
note: Protocol member ModelProtocol.__mapper__ expected instance variable, got class variable
Advanced Alchemy's own bases only pass by MRO accident: UUIDBase lists
CommonTableAttributes (instance-variable declarations) before
AdvancedDeclarativeBase, so the instance-variable view wins.
This module is type-checked by mypy in CI, and at this commit it FAILS
type checking — the fix lands in the next commit. Runtime behavior is
already correct (isinstance passes), so the pytest cases pass throughout;
the static assignments and the subscripted repository are the assertions.
…clarative models satisfy it Declare __table__/__mapper__/__name__ as ClassVar in ModelProtocol, BasicAttributes, and the SQLModelBaseLike placeholder — matching SQLAlchemy's own declarations on DeclarativeBase (sqlalchemy/orm/decl_api.py). All three sites are TYPE_CHECKING-only, so runtime behavior is unchanged. This makes the typing test introduced in the previous commit pass: models built directly on sqlalchemy.orm.DeclarativeBase now satisfy the protocol under mypy and pyright, so external model layers can parameterize repositories and services without stubbing the package. Advanced Alchemy's own bases keep passing (BasicAttributes moves to ClassVar in the same change, which both checkers accept against the ClassVar protocol). Validated downstream on a 775-file codebase with ~55 SQLAlchemyAsyncRepositoryService subclasses over plain declarative models: 110 'must be a subtype of ModelProtocol' [type-var] errors -> 0.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #769 +/- ##
=======================================
Coverage 82.43% 82.43%
=======================================
Files 105 105
Lines 9039 9039
Branches 1219 1219
=======================================
Hits 7451 7451
Misses 1262 1262
Partials 326 326 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Note on CI: the The same All checks that exercise this change pass: mypy, pyright, slotscheck, validate, CodeQL. |
|
Thanks, I'll take a look today |
|
@regularkevvv Can you make it point to https://msgspec.dev/objects.inv instead of https://jcristharif.com/msgspec/objects.inv |
The msgspec docs moved from jcristharif.com/msgspec to msgspec.dev; the old objects.inv now 404s, failing every docs build under warnings-as-errors.
|
Done — pointed at |
Summary
Fixes #768.
ModelProtocoldeclares__table__/__mapper__/__name__as instance variables, whilesqlalchemy.orm.DeclarativeBasedeclares them asClassVar— so under mypy, no model built on plainDeclarativeBasehas ever satisfied the protocol, and every repository/service parameterized with an external model fails theModelTbound. Advanced Alchemy's own bases pass only becauseUUIDBase's MRO happens to listCommonTableAttributesbeforeAdvancedDeclarativeBase.Structure
Two commits, test-first:
test(typing): plain declarative models must satisfy ModelProtocol— addstests/unit/test_model_protocol_typing.py, which reproduces the bug: at this commit the module fails mypy ([assignment]on theModelProtocol-annotated assignments, with the "expected instance variable, got class variable" notes). The pytest cases pass throughout since runtime was never broken — the static assignments are the assertions, and the module is type-checked by mypy in CI. (Note:type-varis disabled fortests.*in the mypy config, so the repository subclass in the test documents the downstream failure mode while the assignments are what guard CI.)fix(typing): declare ModelProtocol attributes as ClassVar so plain declarative models satisfy it— declares the members asClassVarinModelProtocolandBasicAttributes(base.py) and theSQLModelBaseLikeplaceholder (_typing.py), matching SQLAlchemy's own declarations. All three sites areTYPE_CHECKING-only; no runtime change.Verification
make mypy: clean (133 files, strict) — and fails as expected on the test-only commitmake pyright: 0 errorstests/unit: passes (one pre-existing failure on macOS,test_case_insensitive_support, fails identically onmain)SQLAlchemyAsyncRepositoryServicesubclasses over plain declarative models: 110must be a subtype of "ModelProtocol"[type-var]errors → 0📚 Documentation preview: https://litestar-org.github.io/advanced-alchemy-docs-preview/769