Skip to content

fix(typing): declare ModelProtocol attributes as ClassVar so plain declarative models satisfy it#769

Open
regularkevvv wants to merge 3 commits into
litestar-org:mainfrom
regularkevvv:fix/model-protocol-classvar
Open

fix(typing): declare ModelProtocol attributes as ClassVar so plain declarative models satisfy it#769
regularkevvv wants to merge 3 commits into
litestar-org:mainfrom
regularkevvv:fix/model-protocol-classvar

Conversation

@regularkevvv

@regularkevvv regularkevvv commented Jul 9, 2026

Copy link
Copy Markdown

Summary

Fixes #768. ModelProtocol declares __table__/__mapper__/__name__ as instance variables, while sqlalchemy.orm.DeclarativeBase declares them as ClassVar — so under mypy, no model built on plain DeclarativeBase has ever satisfied the protocol, and every repository/service parameterized with an external model fails the ModelT bound. Advanced Alchemy's own bases pass only because UUIDBase's MRO happens to list CommonTableAttributes before AdvancedDeclarativeBase.

Structure

Two commits, test-first:

  1. test(typing): plain declarative models must satisfy ModelProtocol — adds tests/unit/test_model_protocol_typing.py, which reproduces the bug: at this commit the module fails mypy ([assignment] on the ModelProtocol-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-var is disabled for tests.* in the mypy config, so the repository subclass in the test documents the downstream failure mode while the assignments are what guard CI.)
  2. fix(typing): declare ModelProtocol attributes as ClassVar so plain declarative models satisfy it — declares the members as ClassVar in ModelProtocol and BasicAttributes (base.py) and the SQLModelBaseLike placeholder (_typing.py), matching SQLAlchemy's own declarations. All three sites are TYPE_CHECKING-only; no runtime change.

Verification

  • make mypy: clean (133 files, strict) — and fails as expected on the test-only commit
  • make pyright: 0 errors
  • ruff check + format: clean
  • tests/unit: passes (one pre-existing failure on macOS, test_case_insensitive_support, fails identically on main)
  • Validated downstream on a 775-file FastAPI codebase with ~55 SQLAlchemyAsyncRepositoryService subclasses over plain declarative models: 110 must be a subtype of "ModelProtocol" [type-var] errors → 0

📚 Documentation preview: https://litestar-org.github.io/advanced-alchemy-docs-preview/769

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-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.43%. Comparing base (8dc8285) to head (4d16343).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@regularkevvv

Copy link
Copy Markdown
Author

Note on CI: the build-docs failure is pre-existing and unrelated to this change. The Sphinx build runs with warnings-as-errors, and the msgspec intersphinx inventory (https://jcristharif.com/msgspec/objects.inv) currently returns 404 (the site appears to be down), which fails every docs build:

WARNING: failed to reach any of the inventories with the following issues:
intersphinx inventory 'https://jcristharif.com/msgspec/objects.inv' not fetchable due to <class 'requests.exceptions.HTTPError'>: 404 Client Error: Not Found
build finished with problems, 1 warning (with warnings treated as errors).

The same build-docs failure appears on recent PRs that touch no library code at all (e.g. the dependabot workflow-only PRs, runs 28075673711 and 27805878892). Happy to send a separate small PR repointing or dropping that inventory entry if useful.

All checks that exercise this change pass: mypy, pyright, slotscheck, validate, CodeQL.

@Harshal6927

Copy link
Copy Markdown
Member

Thanks, I'll take a look today

@Kumzy

Kumzy commented Jul 10, 2026

Copy link
Copy Markdown
Member

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.
@regularkevvv

Copy link
Copy Markdown
Author

Done — pointed at msgspec.dev in 4d16343.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

4 participants