sqlmodel-mypy-plugin 0.12.3__tar.gz → 0.16.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.16.0}/PKG-INFO +19 -2
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/README.md +18 -1
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/ROADMAP.md +47 -25
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/pyproject.toml +1 -1
- sqlmodel_mypy_plugin-0.16.0/src/sqlmodel_mypy/__init__.py +5 -0
- sqlmodel_mypy_plugin-0.16.0/src/sqlmodel_mypy/persisted.py +35 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/src/sqlmodel_mypy/plugin.py +138 -4
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/src/sqlmodel_mypy/transform.py +267 -2
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/annotated_field_metadata.py +34 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/docs_parity_many_to_many_create_data.py +51 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/docs_parity_read_connected_data.py +48 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/docs_parity_relationship_attributes.py +48 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/docs_parity_update_patterns.py +40 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/joins.py +19 -1
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/modules/persisted_helpers.py +27 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/annotated_field_metadata.py +38 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_many_to_many_create_data.py +52 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_read_connected_data.py +49 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_relationship_attributes.py +49 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/docs_parity_update_patterns.py +41 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +55 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin-strict_ini/persisted_helpers.py +28 -0
- sqlmodel_mypy_plugin-0.16.0/tests/mypy/outputs/mypy-plugin_ini/joins.py +55 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/test_mypy.py +6 -0
- sqlmodel_mypy_plugin-0.16.0/tests/test_persisted_helpers.py +24 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/test_plugin_unit.py +305 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/test_transform_unit.py +105 -0
- sqlmodel_mypy_plugin-0.12.3/src/sqlmodel_mypy/__init__.py +0 -3
- sqlmodel_mypy_plugin-0.12.3/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +0 -35
- 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.16.0}/.gitignore +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/.pre-commit-config.yaml +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/AGENTS.md +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/LICENSE +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/src/sqlmodel_mypy/py.typed +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/conftest.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-no-untyped.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-pydantic-first.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-pydantic-last.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-strict-no-explicit-any.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin-strict.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/mypy-plugin.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/configs/pyproject-plugin.toml +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/aliases.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/execution_options.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/expression_typing_strict.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/field_sa_type.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/generics.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/getattr_column_property.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/inheritance.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/label.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/many_to_many_relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/mapped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/model_config.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/model_construct.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/nullable_defaults.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/relationship_comparators.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/relationship_expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/select_varargs.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/selectinload.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/session_exec.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/session_get.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/table_dunders.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/table_model_awareness.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/modules/untyped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-pydantic-first_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-pydantic-last_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/aliases.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/execution_options.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/generics.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/label.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.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.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/mapped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/model_construct.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/nullable_defaults.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationship_comparators.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin-strict_ini/table_model_awareness.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/aliases.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/execution_options.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/inheritance.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/label.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/many_to_many_relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/model_construct.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/nullable_defaults.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_comparators.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/relationship_expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/table_model_awareness.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/mypy-plugin_ini/untyped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/mypy/outputs/pyproject-plugin_toml/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.3 → sqlmodel_mypy_plugin-0.16.0}/tests/smoke_test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlmodel-mypy-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.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
|
|
@@ -220,6 +220,23 @@ Limitations:
|
|
|
220
220
|
- Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
|
|
221
221
|
missing at type-check time.
|
|
222
222
|
|
|
223
|
+
## Persisted model helpers (opt-in)
|
|
224
|
+
|
|
225
|
+
Strict typing + SQLModel often means table-model IDs start as `None` and are populated after flush/commit/refresh
|
|
226
|
+
(see [SQLModel: Automatic IDs, None, and Refresh](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)).
|
|
227
|
+
Mypy can’t infer those runtime effects, so you can end up with repeated `assert obj.id is not None`.
|
|
228
|
+
|
|
229
|
+
This package ships small helpers you can import (they do **not** affect plugin behavior):
|
|
230
|
+
|
|
231
|
+
```py
|
|
232
|
+
from sqlmodel_mypy import has_id, require_id
|
|
233
|
+
|
|
234
|
+
hero_id: int = require_id(hero) # raises ValueError if hero.id is None
|
|
235
|
+
|
|
236
|
+
if has_id(hero):
|
|
237
|
+
ok_id: int = hero.id
|
|
238
|
+
```
|
|
239
|
+
|
|
223
240
|
## Development
|
|
224
241
|
|
|
225
242
|
- List available commands: `make help`
|
|
@@ -227,7 +244,7 @@ Limitations:
|
|
|
227
244
|
- Typecheck plugin code: `make typecheck`
|
|
228
245
|
- Run tests: `make test`
|
|
229
246
|
- Run all quality gates: `make check`
|
|
230
|
-
- Update mypy
|
|
247
|
+
- Update mypy "golden" outputs: `make update-mypy`
|
|
231
248
|
|
|
232
249
|
See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
|
|
233
250
|
|
|
@@ -174,6 +174,23 @@ Limitations:
|
|
|
174
174
|
- Mypy can’t express “either field name or alias is required”, so aliased required fields may not be reported as
|
|
175
175
|
missing at type-check time.
|
|
176
176
|
|
|
177
|
+
## Persisted model helpers (opt-in)
|
|
178
|
+
|
|
179
|
+
Strict typing + SQLModel often means table-model IDs start as `None` and are populated after flush/commit/refresh
|
|
180
|
+
(see [SQLModel: Automatic IDs, None, and Refresh](https://sqlmodel.tiangolo.com/tutorial/automatic-id-none-refresh/)).
|
|
181
|
+
Mypy can’t infer those runtime effects, so you can end up with repeated `assert obj.id is not None`.
|
|
182
|
+
|
|
183
|
+
This package ships small helpers you can import (they do **not** affect plugin behavior):
|
|
184
|
+
|
|
185
|
+
```py
|
|
186
|
+
from sqlmodel_mypy import has_id, require_id
|
|
187
|
+
|
|
188
|
+
hero_id: int = require_id(hero) # raises ValueError if hero.id is None
|
|
189
|
+
|
|
190
|
+
if has_id(hero):
|
|
191
|
+
ok_id: int = hero.id
|
|
192
|
+
```
|
|
193
|
+
|
|
177
194
|
## Development
|
|
178
195
|
|
|
179
196
|
- List available commands: `make help`
|
|
@@ -181,7 +198,7 @@ Limitations:
|
|
|
181
198
|
- Typecheck plugin code: `make typecheck`
|
|
182
199
|
- Run tests: `make test`
|
|
183
200
|
- Run all quality gates: `make check`
|
|
184
|
-
- Update mypy
|
|
201
|
+
- Update mypy "golden" outputs: `make update-mypy`
|
|
185
202
|
|
|
186
203
|
See [`AGENTS.md`](AGENTS.md) for repo conventions and workflow notes.
|
|
187
204
|
|
|
@@ -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
|
|
@@ -256,7 +234,7 @@ signatures (required/optional + alias kwargs) when `Field(...)` metadata lives i
|
|
|
256
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,7 +251,7 @@ 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
256
|
**Motivation**: strict typing + SQLModel often means the database populates values “later” (after flush/commit/
|
|
279
257
|
refresh), especially primary keys (`id: int | None`). Mypy can’t infer those runtime effects, and users end up with
|
|
@@ -287,3 +265,47 @@ repeated `assert obj.id is not None` (type clutter).
|
|
|
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)
|
|
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 (Recognize `table=True` via `model_config["table"]`, not just class kwargs)
|
|
290
|
+
|
|
291
|
+
**Motivation**: SQLModel’s runtime `is_table_model_class()` checks `model_config.get("table")`, so a class can be a
|
|
292
|
+
table model even when it isn’t declared as `class Model(SQLModel, table=True)`. Today our plugin/transformer only
|
|
293
|
+
looks at the class keyword, so “table-only” behaviors may not activate (expression typing, relationship kwargs in
|
|
294
|
+
constructor signatures, `__table__`, etc.).
|
|
295
|
+
|
|
296
|
+
- **Detect table models via `model_config` (metadata-driven)**
|
|
297
|
+
- Extend semantic analysis in `src/sqlmodel_mypy/transform.py` to detect a statically-known class-body assignment like:
|
|
298
|
+
- `model_config = ConfigDict(table=True)` or equivalent SQLModel config helpers
|
|
299
|
+
- Persist the result in `TypeInfo.metadata["sqlmodel-mypy-metadata"]`, then update `_is_table_model(...)` in:
|
|
300
|
+
- `src/sqlmodel_mypy/plugin.py`
|
|
301
|
+
- `src/sqlmodel_mypy/transform.py`
|
|
302
|
+
to consult metadata first (then fall back to `table=True` in the class definition).
|
|
303
|
+
- Source references:
|
|
304
|
+
- SQLModel metaclass reads config: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py` (`SQLModelMetaclass.__new__`)
|
|
305
|
+
- SQLModel runtime table detection: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/_compat.py` (`is_table_model_class`)
|
|
306
|
+
- **Tests**
|
|
307
|
+
- Add mypy integration coverage for a table model enabled via `model_config` and assert the same behavior as
|
|
308
|
+
`table=True`:
|
|
309
|
+
- class attribute expression typing (e.g. `User.id.in_([1])`)
|
|
310
|
+
- constructor accepts relationship kwargs when configured (`init_forbid_extra=true`)
|
|
311
|
+
- `User.__table__` is typed and present
|
|
@@ -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
|
|
@@ -154,7 +154,7 @@ SQLMODEL_SESSION_EXEC_FULLNAME = "sqlmodel.orm.session.Session.exec"
|
|
|
154
154
|
SQLMODEL_ASYNC_SESSION_EXEC_FULLNAME = "sqlmodel.ext.asyncio.session.AsyncSession.exec"
|
|
155
155
|
|
|
156
156
|
# Increment when plugin changes should invalidate mypy cache.
|
|
157
|
-
__version__ =
|
|
157
|
+
__version__ = 20
|
|
158
158
|
|
|
159
159
|
|
|
160
160
|
class _CollectedField(NamedTuple):
|
|
@@ -187,6 +187,19 @@ def _is_table_model(info: TypeInfo) -> bool:
|
|
|
187
187
|
if kw is not None:
|
|
188
188
|
return _is_bool_nameexpr(kw, True)
|
|
189
189
|
|
|
190
|
+
# Prefer persisted metadata when the class-body AST isn't available
|
|
191
|
+
# (common in incremental mode when loading modules from cache).
|
|
192
|
+
metadata = getattr(info, "metadata", None)
|
|
193
|
+
md = metadata.get(METADATA_KEY) if isinstance(metadata, dict) else None
|
|
194
|
+
if isinstance(md, dict):
|
|
195
|
+
md_table = md.get("is_table_model")
|
|
196
|
+
if isinstance(md_table, bool):
|
|
197
|
+
return md_table
|
|
198
|
+
# Backwards-compatible alias for older metadata shapes.
|
|
199
|
+
md_table = md.get("table")
|
|
200
|
+
if isinstance(md_table, bool):
|
|
201
|
+
return md_table
|
|
202
|
+
|
|
190
203
|
# Inherit `table=True` from bases if present.
|
|
191
204
|
for base in info.mro[1:]:
|
|
192
205
|
if base.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
@@ -194,6 +207,10 @@ def _is_table_model(info: TypeInfo) -> bool:
|
|
|
194
207
|
kw = base.defn.keywords.get("table")
|
|
195
208
|
if kw is not None and _is_bool_nameexpr(kw, True):
|
|
196
209
|
return True
|
|
210
|
+
base_metadata = getattr(base, "metadata", None)
|
|
211
|
+
base_md = base_metadata.get(METADATA_KEY) if isinstance(base_metadata, dict) else None
|
|
212
|
+
if isinstance(base_md, dict) and base_md.get("is_table_model") is True:
|
|
213
|
+
return True
|
|
197
214
|
return False
|
|
198
215
|
|
|
199
216
|
|
|
@@ -628,9 +645,24 @@ class SQLModelMypyPlugin(Plugin):
|
|
|
628
645
|
if sym.node.is_classvar:
|
|
629
646
|
return None
|
|
630
647
|
|
|
631
|
-
has_default = SQLModelTransformer.get_has_default(stmt)
|
|
632
|
-
aliases = tuple(SQLModelTransformer.get_field_aliases(stmt))
|
|
633
648
|
init_type = sym.node.type
|
|
649
|
+
has_default = SQLModelTransformer.get_has_default(stmt)
|
|
650
|
+
aliases: tuple[str, ...] = tuple(SQLModelTransformer.get_field_aliases(stmt))
|
|
651
|
+
|
|
652
|
+
# Prefer semantic-phase metadata when available (keeps checker-time fallback
|
|
653
|
+
# consistent with plugin-generated signatures, including Annotated Field metadata).
|
|
654
|
+
md = defining_info.metadata.get(METADATA_KEY)
|
|
655
|
+
if isinstance(md, dict):
|
|
656
|
+
fields = md.get("fields")
|
|
657
|
+
if isinstance(fields, dict):
|
|
658
|
+
data = fields.get(name)
|
|
659
|
+
if isinstance(data, dict):
|
|
660
|
+
md_has_default = data.get("has_default")
|
|
661
|
+
if isinstance(md_has_default, bool):
|
|
662
|
+
has_default = md_has_default
|
|
663
|
+
md_aliases = data.get("aliases")
|
|
664
|
+
if isinstance(md_aliases, list):
|
|
665
|
+
aliases = tuple(a for a in md_aliases if isinstance(a, str))
|
|
634
666
|
if init_type is not None and defining_info is not current_info:
|
|
635
667
|
with state.strict_optional_set(api.options.strict_optional):
|
|
636
668
|
init_type = map_type_from_supertype(init_type, current_info, defining_info)
|
|
@@ -1064,6 +1096,108 @@ class SQLModelMypyPlugin(Plugin):
|
|
|
1064
1096
|
except Exception:
|
|
1065
1097
|
return _plugin_any()
|
|
1066
1098
|
|
|
1099
|
+
def _typeinfo_from_join_target_expr(self, expr: Expression | None, api: Any) -> TypeInfo | None:
|
|
1100
|
+
"""Return a best-effort `TypeInfo` for a join target expression.
|
|
1101
|
+
|
|
1102
|
+
Supports direct model-class targets (e.g. `join(Team)`) and relationship
|
|
1103
|
+
attribute targets (e.g. `join(Hero.team)`).
|
|
1104
|
+
"""
|
|
1105
|
+
info = _typeinfo_from_ref_expr(expr)
|
|
1106
|
+
if info is not None:
|
|
1107
|
+
return info
|
|
1108
|
+
return self._typeinfo_from_relationship_join_target(expr, api)
|
|
1109
|
+
|
|
1110
|
+
def _typeinfo_from_relationship_join_target(
|
|
1111
|
+
self, expr: Expression | None, api: Any
|
|
1112
|
+
) -> TypeInfo | None:
|
|
1113
|
+
"""Infer join target model type from `Model.relationship` expressions."""
|
|
1114
|
+
if not isinstance(expr, MemberExpr):
|
|
1115
|
+
return None
|
|
1116
|
+
|
|
1117
|
+
owner_info = _typeinfo_from_ref_expr(expr.expr)
|
|
1118
|
+
if owner_info is None:
|
|
1119
|
+
return None
|
|
1120
|
+
if owner_info.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
1121
|
+
return None
|
|
1122
|
+
if not owner_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
|
|
1123
|
+
return None
|
|
1124
|
+
if not _is_table_model(owner_info):
|
|
1125
|
+
return None
|
|
1126
|
+
|
|
1127
|
+
rel_name = expr.name
|
|
1128
|
+
try:
|
|
1129
|
+
_fields, relationships = self._collect_members_for_signature(owner_info, api)
|
|
1130
|
+
except Exception:
|
|
1131
|
+
return None
|
|
1132
|
+
|
|
1133
|
+
rel_type: Type | None = None
|
|
1134
|
+
for rel in relationships:
|
|
1135
|
+
if rel.name == rel_name:
|
|
1136
|
+
rel_type = rel.type
|
|
1137
|
+
break
|
|
1138
|
+
if rel_type is None:
|
|
1139
|
+
return None
|
|
1140
|
+
|
|
1141
|
+
entity_type = self._unwrap_relationship_entity_type(rel_type)
|
|
1142
|
+
if entity_type is None:
|
|
1143
|
+
return None
|
|
1144
|
+
proper = get_proper_type(entity_type)
|
|
1145
|
+
if not isinstance(proper, Instance):
|
|
1146
|
+
return None
|
|
1147
|
+
|
|
1148
|
+
target_info = proper.type
|
|
1149
|
+
if not target_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
|
|
1150
|
+
return None
|
|
1151
|
+
return target_info
|
|
1152
|
+
|
|
1153
|
+
@classmethod
|
|
1154
|
+
def _unwrap_relationship_entity_type(cls, typ: Type) -> Type | None:
|
|
1155
|
+
"""Return the related entity type for relationship annotations.
|
|
1156
|
+
|
|
1157
|
+
Examples:
|
|
1158
|
+
- `Team | None` -> `Team`
|
|
1159
|
+
- `list[Hero]` -> `Hero`
|
|
1160
|
+
- `Mapped[list[Hero]]` -> `Hero`
|
|
1161
|
+
"""
|
|
1162
|
+
typ = _unwrap_mapped_type(typ)
|
|
1163
|
+
proper = get_proper_type(typ)
|
|
1164
|
+
|
|
1165
|
+
if isinstance(proper, UnionType):
|
|
1166
|
+
non_none: list[Type] = []
|
|
1167
|
+
for item in proper.items:
|
|
1168
|
+
if isinstance(get_proper_type(item), NoneType):
|
|
1169
|
+
continue
|
|
1170
|
+
non_none.append(item)
|
|
1171
|
+
if len(non_none) != 1:
|
|
1172
|
+
return None
|
|
1173
|
+
return cls._unwrap_relationship_entity_type(non_none[0])
|
|
1174
|
+
|
|
1175
|
+
if isinstance(proper, Instance):
|
|
1176
|
+
# Relationship types may appear wrapped as ORM descriptor/expression types.
|
|
1177
|
+
if proper.type.fullname in SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES and proper.args:
|
|
1178
|
+
return cls._unwrap_relationship_entity_type(proper.args[0])
|
|
1179
|
+
if (
|
|
1180
|
+
proper.type.fullname == "sqlalchemy.orm.attributes.QueryableAttribute"
|
|
1181
|
+
and proper.args
|
|
1182
|
+
):
|
|
1183
|
+
return cls._unwrap_relationship_entity_type(proper.args[0])
|
|
1184
|
+
|
|
1185
|
+
# One-to-many / many-to-many relationships are commonly annotated as `list[T]`.
|
|
1186
|
+
if proper.args and proper.type.fullname in {
|
|
1187
|
+
"builtins.list",
|
|
1188
|
+
"builtins.set",
|
|
1189
|
+
"builtins.frozenset",
|
|
1190
|
+
"collections.abc.Sequence",
|
|
1191
|
+
"typing.Sequence",
|
|
1192
|
+
"typing.List",
|
|
1193
|
+
"typing.Set",
|
|
1194
|
+
"typing.FrozenSet",
|
|
1195
|
+
}:
|
|
1196
|
+
return cls._unwrap_relationship_entity_type(proper.args[0])
|
|
1197
|
+
return typ
|
|
1198
|
+
|
|
1199
|
+
return None
|
|
1200
|
+
|
|
1067
1201
|
def _sqlalchemy_select_join_like_return_type(
|
|
1068
1202
|
self, ctx: MethodContext, *, target_positional_index: int, isouter_always: bool
|
|
1069
1203
|
) -> Type:
|
|
@@ -1080,7 +1214,7 @@ class SQLModelMypyPlugin(Plugin):
|
|
|
1080
1214
|
return ctx.default_return_type
|
|
1081
1215
|
|
|
1082
1216
|
target_expr = _call_get_arg(call, "target", target_positional_index)
|
|
1083
|
-
target_info =
|
|
1217
|
+
target_info = self._typeinfo_from_join_target_expr(target_expr, ctx.api)
|
|
1084
1218
|
if target_info is None:
|
|
1085
1219
|
return ctx.default_return_type
|
|
1086
1220
|
|