sqlmodel-mypy-plugin 0.12.3__tar.gz → 0.18.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/AGENTS.md +5 -2
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/PKG-INFO +44 -4
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/README.md +43 -3
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/ROADMAP.md +111 -51
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/pyproject.toml +1 -1
- sqlmodel_mypy_plugin-0.18.0/src/sqlmodel_mypy/__init__.py +5 -0
- sqlmodel_mypy_plugin-0.18.0/src/sqlmodel_mypy/persisted.py +35 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/src/sqlmodel_mypy/plugin.py +663 -48
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/src/sqlmodel_mypy/transform.py +339 -2
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/configs/mypy-plugin-strict-typed-execute.ini +20 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/aliased_joins.py +32 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/annotated_field_metadata.py +34 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/docs_parity_many_to_many_create_data.py +51 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/docs_parity_read_connected_data.py +48 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/docs_parity_relationship_attributes.py +48 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/docs_parity_update_patterns.py +40 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/field_validation_alias.py +10 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/getattr_column_property.py +16 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/joins.py +19 -1
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/persisted_helpers.py +27 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/select_varargs.py +36 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/session_execute_typed.py +32 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/table_via_model_config.py +35 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/modules/tuple_expression.py +14 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict-typed-execute_ini/session_execute_typed.py +35 -0
- sqlmodel_mypy_plugin-0.12.3/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py → sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/aliased_joins.py +11 -13
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/annotated_field_metadata.py +38 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_many_to_many_create_data.py +52 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_read_connected_data.py +49 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_relationship_attributes.py +49 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_update_patterns.py +41 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +55 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/persisted_helpers.py +28 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/select_varargs.py +37 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/table_via_model_config.py +37 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin-strict_ini/tuple_expression.py +14 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin_ini/aliased_joins.py +33 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin_ini/joins.py +55 -0
- sqlmodel_mypy_plugin-0.18.0/tests/mypy/outputs/mypy-plugin_ini/select_varargs.py +37 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/test_mypy.py +13 -0
- sqlmodel_mypy_plugin-0.18.0/tests/test_persisted_helpers.py +24 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/test_plugin_unit.py +1336 -116
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/test_transform_unit.py +147 -0
- sqlmodel_mypy_plugin-0.12.3/src/sqlmodel_mypy/__init__.py +0 -3
- sqlmodel_mypy_plugin-0.12.3/tests/mypy/modules/select_varargs.py +0 -18
- sqlmodel_mypy_plugin-0.12.3/tests/mypy/outputs/mypy-plugin_ini/joins.py +0 -35
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/.gitignore +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/.pre-commit-config.yaml +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/LICENSE +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/src/sqlmodel_mypy/py.typed +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/conftest.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-no-untyped.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-pydantic-first.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-pydantic-last.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-strict-no-explicit-any.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin-strict.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/mypy-plugin.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/configs/pyproject-plugin.toml +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/aliases.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/execution_options.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/expression_typing_strict.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/field_sa_type.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/generics.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/inheritance.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/label.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/many_to_many_relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/mapped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/model_config.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/model_construct.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/nullable_defaults.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/relationship_comparators.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/relationship_expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/selectinload.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/session_exec.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/session_get.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/table_dunders.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/table_model_awareness.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/modules/untyped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-pydantic-first_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-pydantic-last_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/aliases.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/execution_options.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/generics.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/label.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/many_to_many_relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/mapped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/model_construct.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/nullable_defaults.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationship_comparators.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin-strict_ini/table_model_awareness.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/aliases.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/execution_options.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/inheritance.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/label.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/many_to_many_relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/model_construct.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/nullable_defaults.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_comparators.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/table_model_awareness.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/mypy-plugin_ini/untyped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/mypy/outputs/pyproject-plugin_toml/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.18.0}/tests/smoke_test.py +0 -0
|
@@ -47,8 +47,11 @@ make update-mypy
|
|
|
47
47
|
## What the plugin does (scope)
|
|
48
48
|
|
|
49
49
|
- Collects SQLModel fields and determines required/optional kwargs based on `sqlmodel.Field(...)`.
|
|
50
|
-
-
|
|
50
|
+
- For `table=True` models, includes `sqlmodel.Relationship(...)` members as **optional** constructor kwargs.
|
|
51
51
|
- Synthesizes `__init__` and `model_construct` signatures for SQLModel subclasses during mypy semantic analysis.
|
|
52
|
+
- Adjusts `table=True` SQLModel class attribute types to behave like SQLAlchemy expressions (e.g. `User.id` and relationship expression/comparator patterns).
|
|
53
|
+
- Exposes SQLAlchemy table metadata on `table=True` models (e.g. `Model.__table__`).
|
|
54
|
+
- Adds targeted typing fixes for common query patterns/helpers (`select(...)` 5+ entities fallback, `sqlmodel.tuple_(...)` as an expression, outer-join `None` propagation, broadened `Session.exec(...)`, optional typed `Session.execute(...)`, and best-effort `column_property(...)` return typing).
|
|
52
55
|
|
|
53
56
|
## Repo layout (where things live)
|
|
54
57
|
|
|
@@ -83,4 +86,4 @@ make update-mypy
|
|
|
83
86
|
- To release:
|
|
84
87
|
- Bump the version.
|
|
85
88
|
- Tag `vX.Y.Z` and push the tag.
|
|
86
|
-
- GitHub Actions publishes to PyPI via **trusted publishing**
|
|
89
|
+
- GitHub Actions publishes to PyPI via **trusted publishing**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlmodel-mypy-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.0
|
|
4
4
|
Summary: Mypy plugin for SQLModel (better constructor/Field typing).
|
|
5
5
|
Project-URL: Homepage, https://github.com/sertione/sqlmodel-mypy-plugin
|
|
6
6
|
Project-URL: Repository, https://github.com/sertione/sqlmodel-mypy-plugin
|
|
@@ -68,11 +68,18 @@ Planned work is tracked in [`ROADMAP.md`](ROADMAP.md).
|
|
|
68
68
|
- Outer-join `None` propagation in `select(A, B).join(B, isouter=True)` result tuples.
|
|
69
69
|
- Broaden `Session.exec()` / `AsyncSession.exec()` typing to accept SQLAlchemy `Executable` statements (e.g.
|
|
70
70
|
`text(...)`), not just `select(...)`.
|
|
71
|
+
- Optional typed `Session.execute()` / `AsyncSession.execute()` for `select(...)` (opt-in; see `typed_execute`).
|
|
71
72
|
- Accept SQLAlchemy TypeEngine instances in `Field(sa_type=...)` (e.g. `DateTime(timezone=True)`, `String(50)`)
|
|
72
73
|
without `# type: ignore` in strict mode.
|
|
73
74
|
- Accept `model_config = ConfigDict(...)` overrides on SQLModel subclasses in strict mode.
|
|
74
75
|
- Compatible with `pydantic.mypy` **when `sqlmodel_mypy.plugin` is listed first**.
|
|
75
76
|
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv add sqlmodel-mypy-plugin
|
|
81
|
+
```
|
|
82
|
+
|
|
76
83
|
## Install (dev)
|
|
77
84
|
|
|
78
85
|
Prereqs: **Python 3.10+** and [`uv`](https://docs.astral.sh/uv/).
|
|
@@ -119,6 +126,9 @@ Supported options:
|
|
|
119
126
|
`__init__` / `model_construct` / constructor signatures, so mypy reports unexpected keyword arguments.
|
|
120
127
|
- **`warn_untyped_fields`** (default: `true`): When `true`, emit error code `sqlmodel-field` for untyped
|
|
121
128
|
declarations like `x = Field(...)` / `x = Relationship(...)` (use `x: T = ...` instead).
|
|
129
|
+
- **`typed_execute`** (default: `false`): When `true`, type SQLModel’s deprecated `Session.execute()` /
|
|
130
|
+
`AsyncSession.execute()` for common `select(...)` statements by recovering `Result[...]` generics. This is meant to
|
|
131
|
+
reduce “type clutter” during migrations; prefer `Session.exec()` for new code.
|
|
122
132
|
- **`debug_dataclass_transform`** (default: `false`): **Advanced/debug-only**. When `true`, keep SQLModel’s
|
|
123
133
|
`__dataclass_transform__` handling enabled in mypy (useful for debugging plugin interactions; not recommended
|
|
124
134
|
for normal use).
|
|
@@ -130,6 +140,7 @@ Supported options:
|
|
|
130
140
|
init_typed = false
|
|
131
141
|
init_forbid_extra = false
|
|
132
142
|
warn_untyped_fields = true
|
|
143
|
+
typed_execute = false
|
|
133
144
|
debug_dataclass_transform = false
|
|
134
145
|
```
|
|
135
146
|
|
|
@@ -140,12 +151,15 @@ debug_dataclass_transform = false
|
|
|
140
151
|
init_typed = false
|
|
141
152
|
init_forbid_extra = false
|
|
142
153
|
warn_untyped_fields = true
|
|
154
|
+
typed_execute = false
|
|
143
155
|
debug_dataclass_transform = false
|
|
144
156
|
```
|
|
145
157
|
|
|
146
158
|
## Error codes
|
|
147
159
|
|
|
148
160
|
- `sqlmodel-field`: field-related plugin errors (e.g. untyped `x = Field(...)`).
|
|
161
|
+
- `sqlmodel-plugin-order`: plugin ordering errors (e.g. `pydantic.mypy` enabled before `sqlmodel_mypy.plugin`).
|
|
162
|
+
Fix: list `sqlmodel_mypy.plugin` first (see [Enable in mypy](#enable-in-mypy)).
|
|
149
163
|
|
|
150
164
|
## SQL expression typing
|
|
151
165
|
|
|
@@ -170,13 +184,22 @@ tbl = User.__table__
|
|
|
170
184
|
|
|
171
185
|
### `getattr(Model, "field")` support
|
|
172
186
|
|
|
173
|
-
If you build query filters dynamically, `getattr(Model, "field")` with a **string literal**
|
|
174
|
-
direct `Model.field` access on `table=True` models:
|
|
187
|
+
If you build query filters dynamically, `getattr(Model, "field")` with a **string literal** (or a `Final` / string
|
|
188
|
+
`Literal[...]` name) is typed the same as direct `Model.field` access on `table=True` models:
|
|
175
189
|
|
|
176
190
|
```py
|
|
177
191
|
stmt = select(User).where(getattr(User, "name").like("%x%"))
|
|
178
192
|
```
|
|
179
193
|
|
|
194
|
+
Example using a `Final` constant:
|
|
195
|
+
|
|
196
|
+
```py
|
|
197
|
+
from typing import Final
|
|
198
|
+
|
|
199
|
+
FIELD: Final = "name"
|
|
200
|
+
stmt = select(User).where(getattr(User, FIELD).like("%x%"))
|
|
201
|
+
```
|
|
202
|
+
|
|
180
203
|
Non-literal names (runtime strings) are intentionally left as `Any` (you’ll still need a cast or a different
|
|
181
204
|
pattern).
|
|
182
205
|
|
|
@@ -220,6 +243,23 @@ Limitations:
|
|
|
220
243
|
- Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
|
|
221
244
|
missing at type-check time.
|
|
222
245
|
|
|
246
|
+
## Persisted model helpers (opt-in)
|
|
247
|
+
|
|
248
|
+
Strict typing + SQLModel often means table-model IDs start as `None` and are populated after flush/commit/refresh
|
|
249
|
+
(see [SQLModel: Automatic IDs, None, and Refresh](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)).
|
|
250
|
+
Mypy can’t infer those runtime effects, so you can end up with repeated `assert obj.id is not None`.
|
|
251
|
+
|
|
252
|
+
This package ships small helpers you can import (they do **not** affect plugin behavior):
|
|
253
|
+
|
|
254
|
+
```py
|
|
255
|
+
from sqlmodel_mypy import has_id, require_id
|
|
256
|
+
|
|
257
|
+
hero_id: int = require_id(hero) # raises ValueError if hero.id is None
|
|
258
|
+
|
|
259
|
+
if has_id(hero):
|
|
260
|
+
ok_id: int = hero.id
|
|
261
|
+
```
|
|
262
|
+
|
|
223
263
|
## Development
|
|
224
264
|
|
|
225
265
|
- List available commands: `make help`
|
|
@@ -227,7 +267,7 @@ Limitations:
|
|
|
227
267
|
- Typecheck plugin code: `make typecheck`
|
|
228
268
|
- Run tests: `make test`
|
|
229
269
|
- Run all quality gates: `make check`
|
|
230
|
-
- Update mypy
|
|
270
|
+
- Update mypy "golden" outputs: `make update-mypy`
|
|
231
271
|
|
|
232
272
|
See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
|
|
233
273
|
|
|
@@ -22,11 +22,18 @@ Planned work is tracked in [`ROADMAP.md`](ROADMAP.md).
|
|
|
22
22
|
- Outer-join `None` propagation in `select(A, B).join(B, isouter=True)` result tuples.
|
|
23
23
|
- Broaden `Session.exec()` / `AsyncSession.exec()` typing to accept SQLAlchemy `Executable` statements (e.g.
|
|
24
24
|
`text(...)`), not just `select(...)`.
|
|
25
|
+
- Optional typed `Session.execute()` / `AsyncSession.execute()` for `select(...)` (opt-in; see `typed_execute`).
|
|
25
26
|
- Accept SQLAlchemy TypeEngine instances in `Field(sa_type=...)` (e.g. `DateTime(timezone=True)`, `String(50)`)
|
|
26
27
|
without `# type: ignore` in strict mode.
|
|
27
28
|
- Accept `model_config = ConfigDict(...)` overrides on SQLModel subclasses in strict mode.
|
|
28
29
|
- Compatible with `pydantic.mypy` **when `sqlmodel_mypy.plugin` is listed first**.
|
|
29
30
|
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv add sqlmodel-mypy-plugin
|
|
35
|
+
```
|
|
36
|
+
|
|
30
37
|
## Install (dev)
|
|
31
38
|
|
|
32
39
|
Prereqs: **Python 3.10+** and [`uv`](https://docs.astral.sh/uv/).
|
|
@@ -73,6 +80,9 @@ Supported options:
|
|
|
73
80
|
`__init__` / `model_construct` / constructor signatures, so mypy reports unexpected keyword arguments.
|
|
74
81
|
- **`warn_untyped_fields`** (default: `true`): When `true`, emit error code `sqlmodel-field` for untyped
|
|
75
82
|
declarations like `x = Field(...)` / `x = Relationship(...)` (use `x: T = ...` instead).
|
|
83
|
+
- **`typed_execute`** (default: `false`): When `true`, type SQLModel’s deprecated `Session.execute()` /
|
|
84
|
+
`AsyncSession.execute()` for common `select(...)` statements by recovering `Result[...]` generics. This is meant to
|
|
85
|
+
reduce “type clutter” during migrations; prefer `Session.exec()` for new code.
|
|
76
86
|
- **`debug_dataclass_transform`** (default: `false`): **Advanced/debug-only**. When `true`, keep SQLModel’s
|
|
77
87
|
`__dataclass_transform__` handling enabled in mypy (useful for debugging plugin interactions; not recommended
|
|
78
88
|
for normal use).
|
|
@@ -84,6 +94,7 @@ Supported options:
|
|
|
84
94
|
init_typed = false
|
|
85
95
|
init_forbid_extra = false
|
|
86
96
|
warn_untyped_fields = true
|
|
97
|
+
typed_execute = false
|
|
87
98
|
debug_dataclass_transform = false
|
|
88
99
|
```
|
|
89
100
|
|
|
@@ -94,12 +105,15 @@ debug_dataclass_transform = false
|
|
|
94
105
|
init_typed = false
|
|
95
106
|
init_forbid_extra = false
|
|
96
107
|
warn_untyped_fields = true
|
|
108
|
+
typed_execute = false
|
|
97
109
|
debug_dataclass_transform = false
|
|
98
110
|
```
|
|
99
111
|
|
|
100
112
|
## Error codes
|
|
101
113
|
|
|
102
114
|
- `sqlmodel-field`: field-related plugin errors (e.g. untyped `x = Field(...)`).
|
|
115
|
+
- `sqlmodel-plugin-order`: plugin ordering errors (e.g. `pydantic.mypy` enabled before `sqlmodel_mypy.plugin`).
|
|
116
|
+
Fix: list `sqlmodel_mypy.plugin` first (see [Enable in mypy](#enable-in-mypy)).
|
|
103
117
|
|
|
104
118
|
## SQL expression typing
|
|
105
119
|
|
|
@@ -124,13 +138,22 @@ tbl = User.__table__
|
|
|
124
138
|
|
|
125
139
|
### `getattr(Model, "field")` support
|
|
126
140
|
|
|
127
|
-
If you build query filters dynamically, `getattr(Model, "field")` with a **string literal**
|
|
128
|
-
direct `Model.field` access on `table=True` models:
|
|
141
|
+
If you build query filters dynamically, `getattr(Model, "field")` with a **string literal** (or a `Final` / string
|
|
142
|
+
`Literal[...]` name) is typed the same as direct `Model.field` access on `table=True` models:
|
|
129
143
|
|
|
130
144
|
```py
|
|
131
145
|
stmt = select(User).where(getattr(User, "name").like("%x%"))
|
|
132
146
|
```
|
|
133
147
|
|
|
148
|
+
Example using a `Final` constant:
|
|
149
|
+
|
|
150
|
+
```py
|
|
151
|
+
from typing import Final
|
|
152
|
+
|
|
153
|
+
FIELD: Final = "name"
|
|
154
|
+
stmt = select(User).where(getattr(User, FIELD).like("%x%"))
|
|
155
|
+
```
|
|
156
|
+
|
|
134
157
|
Non-literal names (runtime strings) are intentionally left as `Any` (you’ll still need a cast or a different
|
|
135
158
|
pattern).
|
|
136
159
|
|
|
@@ -174,6 +197,23 @@ Limitations:
|
|
|
174
197
|
- Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
|
|
175
198
|
missing at type-check time.
|
|
176
199
|
|
|
200
|
+
## Persisted model helpers (opt-in)
|
|
201
|
+
|
|
202
|
+
Strict typing + SQLModel often means table-model IDs start as `None` and are populated after flush/commit/refresh
|
|
203
|
+
(see [SQLModel: Automatic IDs, None, and Refresh](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)).
|
|
204
|
+
Mypy can’t infer those runtime effects, so you can end up with repeated `assert obj.id is not None`.
|
|
205
|
+
|
|
206
|
+
This package ships small helpers you can import (they do **not** affect plugin behavior):
|
|
207
|
+
|
|
208
|
+
```py
|
|
209
|
+
from sqlmodel_mypy import has_id, require_id
|
|
210
|
+
|
|
211
|
+
hero_id: int = require_id(hero) # raises ValueError if hero.id is None
|
|
212
|
+
|
|
213
|
+
if has_id(hero):
|
|
214
|
+
ok_id: int = hero.id
|
|
215
|
+
```
|
|
216
|
+
|
|
177
217
|
## Development
|
|
178
218
|
|
|
179
219
|
- List available commands: `make help`
|
|
@@ -181,7 +221,7 @@ Limitations:
|
|
|
181
221
|
- Typecheck plugin code: `make typecheck`
|
|
182
222
|
- Run tests: `make test`
|
|
183
223
|
- Run all quality gates: `make check`
|
|
184
|
-
- Update mypy
|
|
224
|
+
- Update mypy "golden" outputs: `make update-mypy`
|
|
185
225
|
|
|
186
226
|
See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
|
|
187
227
|
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
- Decide approach: plugin hooks vs stub overlays vs relying on SQLAlchemy typing (documented trade-offs).
|
|
24
24
|
- Compatibility matrix CI across mypy/SQLModel versions (and pin policy).
|
|
25
25
|
- **Plugin ordering guardrails (mypy limitation)**
|
|
26
|
-
- **Reality check**: mypy generally uses the **first plugin that claims a hook**, so true
|
|
26
|
+
- **Reality check**: mypy generally uses the **first plugin that claims a hook**, so true "order independence"
|
|
27
27
|
between `sqlmodel_mypy.plugin` and `pydantic.mypy` is not realistically achievable today.
|
|
28
28
|
- Enforce and clearly explain that `sqlmodel_mypy.plugin` must be listed **before** `pydantic.mypy`:
|
|
29
29
|
- Source: `src/sqlmodel_mypy/plugin.py` (`SQLModelMypyPlugin.__init__`, `_sqlmodel_metaclass_callback()`).
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
|
|
32
32
|
## v0.4 (Docs-aligned relationship constructors) - DONE
|
|
33
33
|
|
|
34
|
-
**Motivation**: SQLModel
|
|
34
|
+
**Motivation**: SQLModel's official docs pass relationship attributes in constructors (e.g. `Hero(team=team)`,
|
|
35
35
|
`Team(heroes=[...])`). v0.4 accepts relationship kwargs in generated `__init__` / `model_construct` for
|
|
36
|
-
`table=True` models, so `init_forbid_extra=true` no longer causes false-positive
|
|
36
|
+
`table=True` models, so `init_forbid_extra=true` no longer causes false-positive "unexpected keyword argument"
|
|
37
37
|
errors for documented patterns.
|
|
38
38
|
|
|
39
39
|
- **Accept relationship kwargs in generated `__init__` / `model_construct` (table models)**
|
|
@@ -63,17 +63,17 @@ errors for documented patterns.
|
|
|
63
63
|
pattern (and keep types conservative).
|
|
64
64
|
- Update/add mypy integration tests to match the chosen strategy.
|
|
65
65
|
|
|
66
|
-
## v0.5 (Model-kind awareness + reduce
|
|
66
|
+
## v0.5 (Model-kind awareness + reduce "surprising" typing) - DONE
|
|
67
67
|
|
|
68
68
|
- **Table-model awareness for SQLAlchemy expression typing**
|
|
69
69
|
- Only treat class attributes as SQLAlchemy expressions (e.g. wrap to `InstrumentedAttribute[T]`) for `table=True`
|
|
70
70
|
SQLModel models.
|
|
71
|
-
- Avoid changing attribute types for
|
|
71
|
+
- Avoid changing attribute types for "data models" (non-table SQLModel subclasses) used for FastAPI schemas, e.g.
|
|
72
72
|
`TeamBase`, `HeroCreate`, `HeroPublic`.
|
|
73
73
|
- Docs: `https://sqlmodel.tiangolo.com/tutorial/fastapi/relationships/`
|
|
74
74
|
|
|
75
75
|
- **Pydantic/Field alias & config parity checkpoints**
|
|
76
|
-
- Verify our generated signatures stay compatible with SQLModel
|
|
76
|
+
- Verify our generated signatures stay compatible with SQLModel's `Field(...)` alias behavior (alias propagation
|
|
77
77
|
and precedence in `validation_alias` / `serialization_alias`).
|
|
78
78
|
- Source: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py` (`Field()` implementation).
|
|
79
79
|
- Document what is supported vs intentionally out of scope when users rely on aliases in constructor kwargs.
|
|
@@ -94,20 +94,20 @@ errors for documented patterns.
|
|
|
94
94
|
- **More query-shape coverage**
|
|
95
95
|
- Multi-entity selects, joins/outer-joins, and relationship-based filters across common SQLModel tutorial patterns.
|
|
96
96
|
- **Document trade-offs (and pick a default strategy)**
|
|
97
|
-
- Plugin hooks vs stub overlays vs relying purely on SQLAlchemy typing (with
|
|
97
|
+
- Plugin hooks vs stub overlays vs relying purely on SQLAlchemy typing (with "when to use what" guidance).
|
|
98
98
|
|
|
99
99
|
## v0.8 (Field `sa_type=` supports SQLAlchemy TypeEngine instances) - DONE
|
|
100
100
|
|
|
101
|
-
**Motivation**: SQLModel
|
|
101
|
+
**Motivation**: SQLModel's `Field(sa_type=...)` is a supported API, but real-world usage commonly passes
|
|
102
102
|
**instantiated SQLAlchemy types** (e.g. `DateTime(timezone=True)`, `String(50)`), which triggers mypy
|
|
103
103
|
`call-overload` errors under `--strict`. Users currently work around this with `sa_column=Column(...)` or
|
|
104
|
-
`# type: ignore`, which is exactly the
|
|
104
|
+
`# type: ignore`, which is exactly the "types clutter" we want to remove.
|
|
105
105
|
|
|
106
106
|
- **Fix mypy `call-overload` for `Field(sa_type=<TypeEngine instance>)`**
|
|
107
107
|
- Upstream issue reports:
|
|
108
108
|
- `https://github.com/fastapi/sqlmodel/discussions/955`
|
|
109
109
|
- `https://github.com/fastapi/sqlmodel/discussions/1228`
|
|
110
|
-
- Upstream pending fix (adjust `sa_type` annotation to match SQLAlchemy
|
|
110
|
+
- Upstream pending fix (adjust `sa_type` annotation to match SQLAlchemy's `Column` type argument rules):
|
|
111
111
|
- `https://github.com/fastapi/sqlmodel/pull/1345`
|
|
112
112
|
- Source references:
|
|
113
113
|
- SQLModel `Field()` signature: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py`
|
|
@@ -122,7 +122,7 @@ errors for documented patterns.
|
|
|
122
122
|
touching runtime behavior.
|
|
123
123
|
|
|
124
124
|
- **Tests**
|
|
125
|
-
- Add a mypy integration module that reproduces the failure in strict mode and asserts it
|
|
125
|
+
- Add a mypy integration module that reproduces the failure in strict mode and asserts it's fixed without
|
|
126
126
|
`# type: ignore`, e.g.:
|
|
127
127
|
- `Field(sa_type=DateTime(timezone=True))`
|
|
128
128
|
- `Field(sa_type=String(50))`
|
|
@@ -133,7 +133,7 @@ errors for documented patterns.
|
|
|
133
133
|
|
|
134
134
|
**Motivation**: SQLModel is built on Pydantic v2, and users often want to set things like `extra="forbid"` on
|
|
135
135
|
schema-like models (e.g. `HeroCreate`, `HeroUpdate`) via `model_config = ConfigDict(...)`. Today this can fail mypy
|
|
136
|
-
strict with an
|
|
136
|
+
strict with an "incompatible types in assignment" error (`ConfigDict` vs `SQLModelConfig`), forcing users into
|
|
137
137
|
`# type: ignore` or `cast(...)`.
|
|
138
138
|
|
|
139
139
|
- **Fix mypy incompatibility for `model_config` overrides**
|
|
@@ -148,7 +148,7 @@ strict with an “incompatible types in assignment” error (`ConfigDict` vs `SQ
|
|
|
148
148
|
so assignment from `ConfigDict(...)` is always accepted.
|
|
149
149
|
- **Plugin adjustment**: during semantic analysis, rewrite the inferred type of `model_config` on SQLModel subclasses
|
|
150
150
|
to a compatible supertype (while keeping runtime untouched).
|
|
151
|
-
- Ensure we don
|
|
151
|
+
- Ensure we don't regress table-model behavior: SQLModel uses `model_config["table"]` and `model_config["registry"]`
|
|
152
152
|
at runtime (see `SQLModelMetaclass.__new__`).
|
|
153
153
|
|
|
154
154
|
- **Tests**
|
|
@@ -160,8 +160,8 @@ strict with an “incompatible types in assignment” error (`ConfigDict` vs `SQ
|
|
|
160
160
|
## v0.10 (`select()` varargs: avoid overload ceiling) - DONE
|
|
161
161
|
|
|
162
162
|
**Motivation**: SQLModel provides its own typed `select()` overloads, but they are generated only up to **4**
|
|
163
|
-
entities. In real code it
|
|
164
|
-
can trigger
|
|
163
|
+
entities. In real code it's common to select more than 4 columns/entities (especially with joins/aggregates), which
|
|
164
|
+
can trigger "No overload variant of `select` matches argument types …" even though runtime works.
|
|
165
165
|
|
|
166
166
|
- **Fix overload ceiling for `sqlmodel.select(...)`**
|
|
167
167
|
- Source reference (generated overloads; note the 4-entity cap):
|
|
@@ -171,26 +171,26 @@ can trigger “No overload variant of `select` matches argument types …” eve
|
|
|
171
171
|
- `https://github.com/fastapi/sqlmodel/issues/271`
|
|
172
172
|
|
|
173
173
|
- **Implementation options (pick default; document trade-offs)**
|
|
174
|
-
- **Stub overlay (likely simplest)**: add a final
|
|
174
|
+
- **Stub overlay (likely simplest)**: add a final "varargs fallback" overload, e.g.:
|
|
175
175
|
- `@overload`
|
|
176
176
|
- `def select(__ent0: Any, __ent1: Any, __ent2: Any, __ent3: Any, __ent4: Any, *entities: Any) -> Select[tuple[Any, ...]]: ...`
|
|
177
177
|
so mypy always has a matching overload beyond 4 args.
|
|
178
178
|
- **Plugin signature hook**: provide a mypy hook for `sqlmodel.sql.expression.select` that adds the same fallback
|
|
179
179
|
overload, keeping the existing 1–4 entity overload precision.
|
|
180
|
-
- Optionally, generate more precise overloads (e.g. up to 8) if we
|
|
180
|
+
- Optionally, generate more precise overloads (e.g. up to 8) if we're willing to carry the maintenance cost.
|
|
181
181
|
|
|
182
182
|
- **Tests**
|
|
183
183
|
- Add a mypy integration module that selects 5+ entities and asserts no `call-overload` errors under strict mode.
|
|
184
184
|
- Decide and lock down the fallback return type (likely `Select[tuple[Any, ...]]`) to keep behavior predictable.
|
|
185
185
|
|
|
186
|
-
## v0.11 (SQLAlchemy
|
|
186
|
+
## v0.11 (SQLAlchemy "extras" coverage + SQLModel `Select` join parity) - DONE
|
|
187
187
|
|
|
188
188
|
- **Lock down common SQLAlchemy helpers used with SQLModel**
|
|
189
189
|
- `.label(...)` on `table=True` model fields typed as SQLAlchemy expressions.
|
|
190
190
|
- ORM loader options like `selectinload(Model.relationship)` on `table=True` relationships.
|
|
191
191
|
- `Select.execution_options(...)` chaining without losing the `Select[...]` generic (including outer-join `None` propagation).
|
|
192
|
-
- **Fix outer-join `None` propagation for SQLModel
|
|
193
|
-
- Apply the join/outerjoin return-type adjustment to both SQLAlchemy `Select` and SQLModel
|
|
192
|
+
- **Fix outer-join `None` propagation for SQLModel's own `Select` wrapper**
|
|
193
|
+
- Apply the join/outerjoin return-type adjustment to both SQLAlchemy `Select` and SQLModel's `sqlmodel.sql._expression_select_cls.Select`.
|
|
194
194
|
- **Tests**
|
|
195
195
|
- Add/extend mypy integration coverage and commit golden outputs for the new cases.
|
|
196
196
|
|
|
@@ -201,7 +201,7 @@ models do provide this at runtime for `table=True`, but type checkers can miss i
|
|
|
201
201
|
|
|
202
202
|
- **Expose and type `__table__` on `table=True` SQLModel models**
|
|
203
203
|
- Provide a plugin-generated `__table__` attribute typed as `sqlalchemy.sql.schema.Table` (with safe fallbacks
|
|
204
|
-
when stubs don
|
|
204
|
+
when stubs don't expose `Table`).
|
|
205
205
|
- **Tests**
|
|
206
206
|
- Add mypy integration coverage for `Model.__table__` and `Session.get` / `AsyncSession.get` with SQLModel
|
|
207
207
|
table models.
|
|
@@ -212,29 +212,7 @@ models do provide this at runtime for `table=True`, but type checkers can miss i
|
|
|
212
212
|
- Best-effort typing for `column_property(...)` return type (infer `Mapped[T]`) to support
|
|
213
213
|
`Model._foo = column_property(...)` patterns.
|
|
214
214
|
|
|
215
|
-
## v0.
|
|
216
|
-
|
|
217
|
-
**Motivation**: the north star is “SQLModel as documented works under `mypy --strict` with minimal casts/ignores”.
|
|
218
|
-
We should lock this down with a dedicated docs-parity integration suite (beyond the current focused unit-style
|
|
219
|
-
mypy modules).
|
|
220
|
-
|
|
221
|
-
- **Add docs-derived mypy integration modules**
|
|
222
|
-
- Create minimal, type-focused modules under `tests/mypy/modules/` that mirror official docs patterns (as close
|
|
223
|
-
as possible, but stripped of runtime/demo noise).
|
|
224
|
-
- Add them to `tests/mypy/test_mypy.py` and commit golden outputs under `tests/mypy/outputs/**`.
|
|
225
|
-
- Target doc pages / patterns:
|
|
226
|
-
- Connected-data joins and outer-joins:
|
|
227
|
-
- Docs: `https://sqlmodel.tiangolo.com/tutorial/connect/read-connected-data/`
|
|
228
|
-
- Relationship attribute creation/update patterns (constructor kwargs + mutation):
|
|
229
|
-
- Docs: `https://sqlmodel.tiangolo.com/tutorial/relationship-attributes/create-and-update-relationships/`
|
|
230
|
-
- Many-to-many relationship creation patterns:
|
|
231
|
-
- Docs: `https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-data/`
|
|
232
|
-
- Update patterns using `sqlmodel_update()` / `model_dump(exclude_unset=True)`:
|
|
233
|
-
- Docs: `https://sqlmodel.tiangolo.com/tutorial/fastapi/update/`
|
|
234
|
-
- Source: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py` (`SQLModel.sqlmodel_update`)
|
|
235
|
-
- Ensure strict-mode expectations are explicit (via `# MYPY:` comments and/or `reveal_type(...)` assertions).
|
|
236
|
-
|
|
237
|
-
## v0.14 (`typing.Annotated[...]` field metadata support) - TODO
|
|
215
|
+
## v0.14 (`typing.Annotated[...]` field metadata support) - DONE
|
|
238
216
|
|
|
239
217
|
**Motivation**: SQLModel (Pydantic v2) supports `Annotated`-driven typing patterns, and users will naturally try to
|
|
240
218
|
use them to reduce `= Field(...)` assignment noise. Under `--strict`, we should still generate correct constructor
|
|
@@ -252,11 +230,11 @@ signatures (required/optional + alias kwargs) when `Field(...)` metadata lives i
|
|
|
252
230
|
(`_collect_member_from_stmt`) so class-call signatures stay consistent with generated `__init__`.
|
|
253
231
|
- Source references:
|
|
254
232
|
- SQLModel `Field()` overloads + alias precedence: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py`
|
|
255
|
-
- `Annotated` handling in SQLModel
|
|
256
|
-
- Release note:
|
|
233
|
+
- `Annotated` handling in SQLModel's type parsing: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/_compat.py`
|
|
234
|
+
- Release note: "Fix support for types with `Optional[Annotated[x, f()]]`…" (PR #1093):
|
|
257
235
|
`https://github.com/fastapi/sqlmodel/pull/1093`
|
|
258
236
|
|
|
259
|
-
## v0.15 (Outer-join `None` propagation for relationship-attribute joins) -
|
|
237
|
+
## v0.15 (Outer-join `None` propagation for relationship-attribute joins) - DONE
|
|
260
238
|
|
|
261
239
|
**Motivation**: we currently propagate `None` on outer-joins only when the join target is a direct model class
|
|
262
240
|
(e.g. `.join(Team, isouter=True)`). A common ORM pattern is to join via relationship attributes
|
|
@@ -273,17 +251,99 @@ signatures (required/optional + alias kwargs) when `Field(...)` metadata lives i
|
|
|
273
251
|
- Add mypy integration coverage under `tests/mypy/modules/` for relationship-attribute joins, including the SQLModel
|
|
274
252
|
docs pattern of `Team: None` on outer-joins.
|
|
275
253
|
|
|
276
|
-
## v0.16 (Strict typing ergonomics add-ons, opt-in) -
|
|
254
|
+
## v0.16 (Strict typing ergonomics add-ons, opt-in) - DONE
|
|
277
255
|
|
|
278
|
-
**Motivation**: strict typing + SQLModel often means the database populates values
|
|
279
|
-
refresh), especially primary keys (`id: int | None`). Mypy can
|
|
256
|
+
**Motivation**: strict typing + SQLModel often means the database populates values "later" (after flush/commit/
|
|
257
|
+
refresh), especially primary keys (`id: int | None`). Mypy can't infer those runtime effects, and users end up with
|
|
280
258
|
repeated `assert obj.id is not None` (type clutter).
|
|
281
259
|
|
|
282
260
|
- **Provide a tiny opt-in narrowing helper**
|
|
283
261
|
- Docs context (why `id` starts as `None` and is populated after commit/refresh):
|
|
284
262
|
- `https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/`
|
|
285
|
-
- Ship a small helper in this package (runtime-free or near-zero runtime) that helps users narrow
|
|
263
|
+
- Ship a small helper in this package (runtime-free or near-zero runtime) that helps users narrow "persisted" models
|
|
286
264
|
without `cast(...)`/`# type: ignore`, e.g.:
|
|
287
265
|
- `assert_persisted(obj)` / `require_id(obj)` / `ensure_persisted(obj)` returning a `TypeGuard[...]` or using
|
|
288
266
|
overloads to narrow `id` to `int`.
|
|
289
267
|
- Keep it optional and well-documented; default plugin behavior should remain conservative.
|
|
268
|
+
|
|
269
|
+
## v0.17 (`tuple_()` should return a SQL expression, not a Python tuple) - DONE
|
|
270
|
+
|
|
271
|
+
**Motivation**: SQLModel re-exports `tuple_` from `sqlmodel.sql.expression`, but its typing currently says it returns a
|
|
272
|
+
built-in `tuple[Any, ...]`. In SQLAlchemy, `tuple_()` returns a SQL expression object (`sqlalchemy.sql.elements.Tuple`)
|
|
273
|
+
which supports expression methods like `.in_(...)`. Under `--strict` this mismatch forces `cast(...)` / `# type: ignore`
|
|
274
|
+
for otherwise normal SQLAlchemy patterns.
|
|
275
|
+
|
|
276
|
+
- **Fix `sqlmodel.tuple_` / `sqlmodel.sql.expression.tuple_` return type**
|
|
277
|
+
- Add a plugin return-type hook for:
|
|
278
|
+
- `sqlmodel.sql.expression.tuple_`
|
|
279
|
+
- `sqlmodel.tuple_` (re-export)
|
|
280
|
+
- Prefer returning `sqlalchemy.sql.elements.Tuple` (or fall back to `ColumnElement[tuple[Any, ...]]` if stubs vary).
|
|
281
|
+
- Source references:
|
|
282
|
+
- SQLModel wrapper: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/sql/expression.py`
|
|
283
|
+
- SQLAlchemy `tuple_` constructor: `https://github.com/sqlalchemy/sqlalchemy/blob/main/lib/sqlalchemy/sql/_elements_constructors.py`
|
|
284
|
+
- SQLAlchemy `Tuple` expression class: `https://github.com/sqlalchemy/sqlalchemy/blob/main/lib/sqlalchemy/sql/elements.py`
|
|
285
|
+
- **Tests**
|
|
286
|
+
- Add a mypy integration module demonstrating composite IN typing without ignores, e.g.:
|
|
287
|
+
- `tuple_(User.id, User.team_id).in_([(1, 2)])`
|
|
288
|
+
|
|
289
|
+
## v0.18 (Comprehensive typing polish) - DONE
|
|
290
|
+
|
|
291
|
+
This release consolidates several enhancements to round out SQLModel/mypy strict-mode ergonomics.
|
|
292
|
+
|
|
293
|
+
### Recognize `table=True` via `model_config["table"]`
|
|
294
|
+
|
|
295
|
+
SQLModel's runtime `is_table_model_class()` checks `model_config.get("table")`, so a class can be a table model even
|
|
296
|
+
without `class Model(SQLModel, table=True)`.
|
|
297
|
+
|
|
298
|
+
- Extend semantic analysis in `src/sqlmodel_mypy/transform.py` to detect `model_config = ConfigDict(table=True)`.
|
|
299
|
+
- Persist the result in `TypeInfo.metadata["sqlmodel-mypy-metadata"]`, then update `_is_table_model(...)` in both
|
|
300
|
+
`plugin.py` and `transform.py` to consult metadata first.
|
|
301
|
+
- Source references:
|
|
302
|
+
- SQLModel metaclass: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py` (`SQLModelMetaclass.__new__`)
|
|
303
|
+
- SQLModel runtime table detection: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/_compat.py`
|
|
304
|
+
|
|
305
|
+
### `select(5+)`: recover tuple element types
|
|
306
|
+
|
|
307
|
+
v0.10 fixed the overload ceiling by accepting `select(5+)`, but the fallback return type was `Select[tuple[Any, ...]]`.
|
|
308
|
+
|
|
309
|
+
- Add a best-effort return-type hook that recovers element types from inferred argument expression types.
|
|
310
|
+
- Code: `_sqlmodel_select_signature_callback()`, `_sqlmodel_select_return_type_callback()`, `_sqlmodel_select_entity_result_type()`
|
|
311
|
+
- Tests: `tests/mypy/modules/select_varargs.py`
|
|
312
|
+
|
|
313
|
+
### Outer-join `None` propagation for aliased/variable join targets
|
|
314
|
+
|
|
315
|
+
Outer-join `None` propagation previously only worked when the join target was a direct model ref or relationship
|
|
316
|
+
attribute, not when users join via an alias variable (e.g. `team_alias = aliased(Team)`).
|
|
317
|
+
|
|
318
|
+
- Extend join target type inference to handle typed variables (including `aliased(Model)` results).
|
|
319
|
+
- Code: `_typeinfo_from_join_target_expr()`, `_typeinfo_from_typed_join_target_expr()`, `_typeinfo_from_typed_join_target_type()`
|
|
320
|
+
- Tests: `tests/mypy/modules/aliased_joins.py`
|
|
321
|
+
|
|
322
|
+
### `getattr(Model, NAME)`: support `Final`/`Literal[...]` names
|
|
323
|
+
|
|
324
|
+
Dynamic query builders often use constants for attribute names; without this, `getattr(...)` falls back to `Any`.
|
|
325
|
+
|
|
326
|
+
- Extend `getattr(Model, NAME)` typing beyond string literals by accepting name args typed as string `Literal[...]`
|
|
327
|
+
(and best-effort `Final` constants where mypy retains the literal).
|
|
328
|
+
- Code: `_sqlmodel_getattr_return_type_callback()`
|
|
329
|
+
- Tests: `tests/mypy/modules/getattr_column_property.py`
|
|
330
|
+
|
|
331
|
+
### `Field(validation_alias=...)`: Pydantic v2 alias parity
|
|
332
|
+
|
|
333
|
+
Pydantic v2 supports `validation_alias=AliasPath(...)` / `AliasChoices(...)`, but SQLModel's `Field(...)` typing is
|
|
334
|
+
narrower; strict mypy users otherwise need `cast(...)`/`# type: ignore`.
|
|
335
|
+
|
|
336
|
+
- Widen `Field(validation_alias=...)` to accept `pydantic.aliases.AliasPath | AliasChoices` (idempotent if upstream
|
|
337
|
+
is already widened).
|
|
338
|
+
- Code: `_sqlmodel_field_signature_callback()`
|
|
339
|
+
- Tests: `tests/mypy/modules/field_validation_alias.py`
|
|
340
|
+
|
|
341
|
+
### Optional typed `Session.execute()` / `AsyncSession.execute()`
|
|
342
|
+
|
|
343
|
+
SQLModel overrides `execute()` and types it as `Result[Any]`, shadowing SQLAlchemy's typed overloads. Even though
|
|
344
|
+
`execute()` is deprecated in SQLModel docs, users still rely on it during migrations.
|
|
345
|
+
|
|
346
|
+
- Add an opt-in mypy plugin behavior that types `execute(select(...))` as `Result[...]` (and preserves async wrapping).
|
|
347
|
+
- Config: `typed_execute = true` under `[sqlmodel-mypy]`
|
|
348
|
+
- Code: `SQLModelPluginConfig.typed_execute`, `_sqlmodel_session_execute_return_type_callback()`, `_row_tuple_type_from_typed_statement()`
|
|
349
|
+
- Tests: `tests/mypy/configs/mypy-plugin-strict-typed-execute.ini`, `tests/mypy/modules/session_execute_typed.py`
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, TypeVar
|
|
4
|
+
|
|
5
|
+
from typing_extensions import TypeIs
|
|
6
|
+
|
|
7
|
+
TId_co = TypeVar("TId_co", covariant=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HasOptionalId(Protocol[TId_co]):
|
|
11
|
+
"""An object with an optional primary key (populated later by the DB)."""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def id(self) -> TId_co | None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HasId(HasOptionalId[TId_co], Protocol[TId_co]):
|
|
18
|
+
"""An object with a non-optional primary key."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def id(self) -> TId_co: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def has_id(obj: HasOptionalId[TId_co]) -> TypeIs[HasId[TId_co]]:
|
|
25
|
+
"""Return True if `obj.id` is not None, and narrow `obj.id` accordingly."""
|
|
26
|
+
|
|
27
|
+
return obj.id is not None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_id(obj: HasOptionalId[TId_co]) -> TId_co:
|
|
31
|
+
"""Return `obj.id` if present; raise if it's None."""
|
|
32
|
+
|
|
33
|
+
if obj.id is None:
|
|
34
|
+
raise ValueError("Expected object to have a non-None `id` (persisted object).")
|
|
35
|
+
return obj.id
|