sqlmodel-mypy-plugin 0.12.2__tar.gz → 0.12.3__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.2 → sqlmodel_mypy_plugin-0.12.3}/PKG-INFO +18 -4
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/README.md +17 -3
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/ROADMAP.md +88 -1
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/pyproject.toml +1 -1
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/src/sqlmodel_mypy/__init__.py +1 -1
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/src/sqlmodel_mypy/plugin.py +205 -1
- sqlmodel_mypy_plugin-0.12.3/tests/mypy/modules/getattr_column_property.py +45 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/test_mypy.py +2 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/test_plugin_unit.py +293 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/.gitignore +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/.pre-commit-config.yaml +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/AGENTS.md +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/LICENSE +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/src/sqlmodel_mypy/py.typed +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/src/sqlmodel_mypy/transform.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/conftest.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-no-untyped.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-pydantic-first.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-pydantic-last.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-strict-no-explicit-any.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin-strict.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin.ini +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/pyproject-plugin.toml +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/aliases.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/execution_options.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/expression_typing_strict.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/field_sa_type.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/generics.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/inheritance.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/joins.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/label.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/many_to_many_relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/mapped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/model_config.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/model_construct.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/nullable_defaults.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/relationship_comparators.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/relationship_expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/select_varargs.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/selectinload.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/session_exec.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/session_get.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/table_dunders.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/table_model_awareness.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/untyped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-pydantic-first_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-pydantic-last_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/aliases.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/execution_options.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/generics.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/joins.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/label.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/many_to_many_relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/mapped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/model_construct.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/nullable_defaults.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/relationship_comparators.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin-strict_ini/table_model_awareness.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/aliases.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/execution_options.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/inheritance.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/joins.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/label.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/many_to_many_relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/model_construct.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/nullable_defaults.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/relationship_comparators.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/relationship_expressions.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/relationships.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/table_model_awareness.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/mypy-plugin_ini/untyped_fields.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/outputs/pyproject-plugin_toml/constructor.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/smoke_test.py +0 -0
- {sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/test_transform_unit.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlmodel-mypy-plugin
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.3
|
|
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
|
|
@@ -168,6 +168,18 @@ You can also use SQLAlchemy table metadata attributes without `attr-defined` noi
|
|
|
168
168
|
tbl = User.__table__
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
+
### `getattr(Model, "field")` support
|
|
172
|
+
|
|
173
|
+
If you build query filters dynamically, `getattr(Model, "field")` with a **string literal** is typed the same as
|
|
174
|
+
direct `Model.field` access on `table=True` models:
|
|
175
|
+
|
|
176
|
+
```py
|
|
177
|
+
stmt = select(User).where(getattr(User, "name").like("%x%"))
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Non-literal names (runtime strings) are intentionally left as `Any` (you’ll still need a cast or a different
|
|
181
|
+
pattern).
|
|
182
|
+
|
|
171
183
|
### Relationship comparator typing
|
|
172
184
|
|
|
173
185
|
Relationship attributes declared via `Relationship(...)` are typed as SQLAlchemy expressions at class level,
|
|
@@ -181,9 +193,11 @@ stmt = select(Team).where(Team.heroes.contains(hero_obj))
|
|
|
181
193
|
|
|
182
194
|
### Out of scope
|
|
183
195
|
|
|
184
|
-
-
|
|
185
|
-
|
|
186
|
-
`
|
|
196
|
+
- **Most SQLAlchemy-only declaration helpers**: this plugin doesn’t try to “fix” the full *declaration-site* typing
|
|
197
|
+
for SQLAlchemy ORM APIs that aren’t part of SQLModel’s surface area.
|
|
198
|
+
- Exception: best-effort return typing for `column_property(...)` (infers `Mapped[T]` from `ScalarSelect[T]` /
|
|
199
|
+
`ColumnElement[T]`) to support patterns like `Model._foo = column_property(...)` combined with
|
|
200
|
+
`getattr(Model, "_foo")` in query builders.
|
|
187
201
|
- **Pydantic `@computed_field`**: not touched by this plugin (it should type-check via normal property typing / Pydantic
|
|
188
202
|
typing).
|
|
189
203
|
|
|
@@ -122,6 +122,18 @@ You can also use SQLAlchemy table metadata attributes without `attr-defined` noi
|
|
|
122
122
|
tbl = User.__table__
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
### `getattr(Model, "field")` support
|
|
126
|
+
|
|
127
|
+
If you build query filters dynamically, `getattr(Model, "field")` with a **string literal** is typed the same as
|
|
128
|
+
direct `Model.field` access on `table=True` models:
|
|
129
|
+
|
|
130
|
+
```py
|
|
131
|
+
stmt = select(User).where(getattr(User, "name").like("%x%"))
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Non-literal names (runtime strings) are intentionally left as `Any` (you’ll still need a cast or a different
|
|
135
|
+
pattern).
|
|
136
|
+
|
|
125
137
|
### Relationship comparator typing
|
|
126
138
|
|
|
127
139
|
Relationship attributes declared via `Relationship(...)` are typed as SQLAlchemy expressions at class level,
|
|
@@ -135,9 +147,11 @@ stmt = select(Team).where(Team.heroes.contains(hero_obj))
|
|
|
135
147
|
|
|
136
148
|
### Out of scope
|
|
137
149
|
|
|
138
|
-
-
|
|
139
|
-
|
|
140
|
-
`
|
|
150
|
+
- **Most SQLAlchemy-only declaration helpers**: this plugin doesn’t try to “fix” the full *declaration-site* typing
|
|
151
|
+
for SQLAlchemy ORM APIs that aren’t part of SQLModel’s surface area.
|
|
152
|
+
- Exception: best-effort return typing for `column_property(...)` (infers `Mapped[T]` from `ScalarSelect[T]` /
|
|
153
|
+
`ColumnElement[T]`) to support patterns like `Model._foo = column_property(...)` combined with
|
|
154
|
+
`getattr(Model, "_foo")` in query builders.
|
|
141
155
|
- **Pydantic `@computed_field`**: not touched by this plugin (it should type-check via normal property typing / Pydantic
|
|
142
156
|
typing).
|
|
143
157
|
|
|
@@ -22,7 +22,12 @@
|
|
|
22
22
|
- Ensure common SQLModel re-exports (`select`, `and_`, `or_`, `col`, etc.) preserve SQLAlchemy typing.
|
|
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 “order independence”
|
|
27
|
+
between `sqlmodel_mypy.plugin` and `pydantic.mypy` is not realistically achievable today.
|
|
28
|
+
- Enforce and clearly explain that `sqlmodel_mypy.plugin` must be listed **before** `pydantic.mypy`:
|
|
29
|
+
- Source: `src/sqlmodel_mypy/plugin.py` (`SQLModelMypyPlugin.__init__`, `_sqlmodel_metaclass_callback()`).
|
|
30
|
+
- User-facing docs: `README.md` (plugin ordering section).
|
|
26
31
|
|
|
27
32
|
## v0.4 (Docs-aligned relationship constructors) - DONE
|
|
28
33
|
|
|
@@ -200,3 +205,85 @@ models do provide this at runtime for `table=True`, but type checkers can miss i
|
|
|
200
205
|
- **Tests**
|
|
201
206
|
- Add mypy integration coverage for `Model.__table__` and `Session.get` / `AsyncSession.get` with SQLModel
|
|
202
207
|
table models.
|
|
208
|
+
|
|
209
|
+
## v0.12.4 (Dynamic query-builder helpers) - DONE
|
|
210
|
+
|
|
211
|
+
- Type `getattr(Model, "field")` like `Model.field` for `table=True` SQLModel models (string-literal names only).
|
|
212
|
+
- Best-effort typing for `column_property(...)` return type (infer `Mapped[T]`) to support
|
|
213
|
+
`Model._foo = column_property(...)` patterns.
|
|
214
|
+
|
|
215
|
+
## v0.13 (SQLModel docs parity suite) - TODO
|
|
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
|
|
238
|
+
|
|
239
|
+
**Motivation**: SQLModel (Pydantic v2) supports `Annotated`-driven typing patterns, and users will naturally try to
|
|
240
|
+
use them to reduce `= Field(...)` assignment noise. Under `--strict`, we should still generate correct constructor
|
|
241
|
+
signatures (required/optional + alias kwargs) when `Field(...)` metadata lives inside `Annotated[...]`.
|
|
242
|
+
|
|
243
|
+
- **Support `Annotated[T, Field(...)]` for constructor/signature generation**
|
|
244
|
+
- Examples we should handle (at least best-effort):
|
|
245
|
+
- `id: Annotated[int | None, Field(default=None, primary_key=True)]`
|
|
246
|
+
- `name: Annotated[str, Field(alias="full_name")]`
|
|
247
|
+
- The historical SQLModel edge-case: `Optional[Annotated[T, ...]]` (see upstream release notes).
|
|
248
|
+
- Implementation sketch:
|
|
249
|
+
- Extend semantic-phase collection in `src/sqlmodel_mypy/transform.py` to extract Field metadata from the
|
|
250
|
+
annotation when there is no `= Field(...)` call in the class body.
|
|
251
|
+
- Mirror the same logic in checker-time signature collection in `src/sqlmodel_mypy/plugin.py`
|
|
252
|
+
(`_collect_member_from_stmt`) so class-call signatures stay consistent with generated `__init__`.
|
|
253
|
+
- Source references:
|
|
254
|
+
- SQLModel `Field()` overloads + alias precedence: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/main.py`
|
|
255
|
+
- `Annotated` handling in SQLModel’s type parsing: `https://github.com/fastapi/sqlmodel/blob/main/sqlmodel/_compat.py`
|
|
256
|
+
- Release note: “Fix support for types with `Optional[Annotated[x, f()]]`…” (PR #1093):
|
|
257
|
+
`https://github.com/fastapi/sqlmodel/pull/1093`
|
|
258
|
+
|
|
259
|
+
## v0.15 (Outer-join `None` propagation for relationship-attribute joins) - TODO
|
|
260
|
+
|
|
261
|
+
**Motivation**: we currently propagate `None` on outer-joins only when the join target is a direct model class
|
|
262
|
+
(e.g. `.join(Team, isouter=True)`). A common ORM pattern is to join via relationship attributes
|
|
263
|
+
(e.g. `.join(Hero.team, isouter=True)`), and the result tuple should still reflect that the joined entity may be
|
|
264
|
+
`None`.
|
|
265
|
+
|
|
266
|
+
- **Expand join-target detection**
|
|
267
|
+
- Extend `src/sqlmodel_mypy/plugin.py` (`_sqlalchemy_select_join_like_return_type`) to also detect join targets
|
|
268
|
+
expressed as relationship attributes (e.g. `Hero.team`, `Team.heroes`) and map them to the target model type
|
|
269
|
+
for `None` propagation.
|
|
270
|
+
- References:
|
|
271
|
+
- SQLModel outer-join behavior (baseline example): `https://sqlmodel.tiangolo.com/tutorial/connect/read-connected-data/`
|
|
272
|
+
- SQLAlchemy relationship-attribute joins (`select(User).join(User.addresses)`): `https://docs.sqlalchemy.org/en/20/orm/queryguide/select.html#simple-relationship-joins`
|
|
273
|
+
- Add mypy integration coverage under `tests/mypy/modules/` for relationship-attribute joins, including the SQLModel
|
|
274
|
+
docs pattern of `Team: None` on outer-joins.
|
|
275
|
+
|
|
276
|
+
## v0.16 (Strict typing ergonomics add-ons, opt-in) - TODO
|
|
277
|
+
|
|
278
|
+
**Motivation**: strict typing + SQLModel often means the database populates values “later” (after flush/commit/
|
|
279
|
+
refresh), especially primary keys (`id: int | None`). Mypy can’t infer those runtime effects, and users end up with
|
|
280
|
+
repeated `assert obj.id is not None` (type clutter).
|
|
281
|
+
|
|
282
|
+
- **Provide a tiny opt-in narrowing helper**
|
|
283
|
+
- Docs context (why `id` starts as `None` and is populated after commit/refresh):
|
|
284
|
+
- `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 “persisted” models
|
|
286
|
+
without `cast(...)`/`# type: ignore`, e.g.:
|
|
287
|
+
- `assert_persisted(obj)` / `require_id(obj)` / `ensure_persisted(obj)` returning a `TypeGuard[...]` or using
|
|
288
|
+
overloads to narrow `id` to `int`.
|
|
289
|
+
- Keep it optional and well-documented; default plugin behavior should remain conservative.
|
|
@@ -27,6 +27,7 @@ from mypy.nodes import (
|
|
|
27
27
|
MemberExpr,
|
|
28
28
|
NameExpr,
|
|
29
29
|
RefExpr,
|
|
30
|
+
StrExpr,
|
|
30
31
|
SymbolTableNode,
|
|
31
32
|
TypeInfo,
|
|
32
33
|
Var,
|
|
@@ -81,6 +82,8 @@ ERROR_PLUGIN_ORDER = ErrorCode(
|
|
|
81
82
|
"SQLModel",
|
|
82
83
|
)
|
|
83
84
|
|
|
85
|
+
BUILTINS_GETATTR_FULLNAME = "builtins.getattr"
|
|
86
|
+
|
|
84
87
|
SQLMODEL_COL_FULLNAME = "sqlmodel.sql.expression.col"
|
|
85
88
|
# SQLModel's `select()` is implemented in a generated module and re-exported.
|
|
86
89
|
SQLMODEL_SELECT_GEN_FULLNAME = "sqlmodel.sql._expression_select_gen.select"
|
|
@@ -95,6 +98,13 @@ SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES = {
|
|
|
95
98
|
"sqlalchemy.orm.attributes.InstrumentedAttribute",
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
SQLALCHEMY_COLUMN_PROPERTY_FULLNAMES = {
|
|
102
|
+
# Imported from `sqlalchemy.orm` (common user import).
|
|
103
|
+
"sqlalchemy.orm.column_property",
|
|
104
|
+
# May resolve to the internal defining module depending on stubs/version.
|
|
105
|
+
"sqlalchemy.orm._orm_constructors.column_property",
|
|
106
|
+
}
|
|
107
|
+
|
|
98
108
|
SQLALCHEMY_SELECT_FULLNAME = "sqlalchemy.sql.selectable.Select"
|
|
99
109
|
SQLALCHEMY_SELECT_JOIN_FULLNAME = f"{SQLALCHEMY_SELECT_FULLNAME}.join"
|
|
100
110
|
SQLALCHEMY_SELECT_JOIN_FROM_FULLNAME = f"{SQLALCHEMY_SELECT_FULLNAME}.join_from"
|
|
@@ -144,7 +154,7 @@ SQLMODEL_SESSION_EXEC_FULLNAME = "sqlmodel.orm.session.Session.exec"
|
|
|
144
154
|
SQLMODEL_ASYNC_SESSION_EXEC_FULLNAME = "sqlmodel.ext.asyncio.session.AsyncSession.exec"
|
|
145
155
|
|
|
146
156
|
# Increment when plugin changes should invalidate mypy cache.
|
|
147
|
-
__version__ =
|
|
157
|
+
__version__ = 17
|
|
148
158
|
|
|
149
159
|
|
|
150
160
|
class _CollectedField(NamedTuple):
|
|
@@ -456,6 +466,10 @@ class SQLModelMypyPlugin(Plugin):
|
|
|
456
466
|
def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] | None:
|
|
457
467
|
if fullname == SQLMODEL_COL_FULLNAME:
|
|
458
468
|
return self._sqlmodel_col_return_type_callback
|
|
469
|
+
if fullname == BUILTINS_GETATTR_FULLNAME:
|
|
470
|
+
return self._sqlmodel_getattr_return_type_callback
|
|
471
|
+
if fullname in SQLALCHEMY_COLUMN_PROPERTY_FULLNAMES:
|
|
472
|
+
return self._sqlalchemy_column_property_return_type_callback
|
|
459
473
|
return None
|
|
460
474
|
|
|
461
475
|
def report_config_data(self, ctx: ReportConfigContext) -> dict[str, Any]:
|
|
@@ -1159,6 +1173,196 @@ class SQLModelMypyPlugin(Plugin):
|
|
|
1159
1173
|
return Instance(info, [value_type])
|
|
1160
1174
|
return ctx.default_return_type
|
|
1161
1175
|
|
|
1176
|
+
@staticmethod
|
|
1177
|
+
def _lookup_var_in_mro(info: TypeInfo, name: str) -> Var | None:
|
|
1178
|
+
"""Find a `Var` named `name` in `info` or its MRO (best-effort)."""
|
|
1179
|
+
for base in info.mro:
|
|
1180
|
+
sym = base.names.get(name)
|
|
1181
|
+
if sym is not None and isinstance(sym.node, Var):
|
|
1182
|
+
return sym.node
|
|
1183
|
+
return None
|
|
1184
|
+
|
|
1185
|
+
@staticmethod
|
|
1186
|
+
def _is_sqlalchemy_expressionish(tp: Type) -> bool:
|
|
1187
|
+
"""Return True if `tp` already looks like a SQLAlchemy expression/descriptor type."""
|
|
1188
|
+
proper = get_proper_type(tp)
|
|
1189
|
+
if not isinstance(proper, Instance):
|
|
1190
|
+
return False
|
|
1191
|
+
if proper.type.fullname in SQLALCHEMY_MAPPED_FULLNAMES:
|
|
1192
|
+
return True
|
|
1193
|
+
if proper.type.fullname in SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES:
|
|
1194
|
+
return True
|
|
1195
|
+
if proper.type.fullname == "sqlalchemy.orm.attributes.QueryableAttribute":
|
|
1196
|
+
return True
|
|
1197
|
+
if proper.type.fullname in {
|
|
1198
|
+
"sqlalchemy.sql.elements.ColumnElement",
|
|
1199
|
+
"sqlalchemy.sql.expression.ColumnElement",
|
|
1200
|
+
}:
|
|
1201
|
+
return True
|
|
1202
|
+
return False
|
|
1203
|
+
|
|
1204
|
+
def _sqlalchemy_instrumented_attribute_type(self, api: Any, value_type: Type) -> Type | None:
|
|
1205
|
+
for fullname in (
|
|
1206
|
+
"sqlalchemy.orm.attributes.InstrumentedAttribute",
|
|
1207
|
+
"sqlalchemy.orm.InstrumentedAttribute",
|
|
1208
|
+
):
|
|
1209
|
+
try:
|
|
1210
|
+
return cast(Type, api.named_generic_type(fullname, [value_type]))
|
|
1211
|
+
except Exception:
|
|
1212
|
+
continue
|
|
1213
|
+
return None
|
|
1214
|
+
|
|
1215
|
+
def _sqlalchemy_column_element_type(self, api: Any, value_type: Type) -> Type | None:
|
|
1216
|
+
for fullname in (
|
|
1217
|
+
"sqlalchemy.sql.elements.ColumnElement",
|
|
1218
|
+
"sqlalchemy.sql.expression.ColumnElement",
|
|
1219
|
+
):
|
|
1220
|
+
try:
|
|
1221
|
+
return cast(Type, api.named_generic_type(fullname, [value_type]))
|
|
1222
|
+
except Exception:
|
|
1223
|
+
continue
|
|
1224
|
+
return None
|
|
1225
|
+
|
|
1226
|
+
def _sqlalchemy_expr_type_for_class_attr(self, api: Any, declared_type: Type) -> Type:
|
|
1227
|
+
"""Return the type of `Model.attr` in SQL expressions, given declared instance type.
|
|
1228
|
+
|
|
1229
|
+
This mirrors `_sqlmodel_class_attr_type_callback` but works from a `Type`.
|
|
1230
|
+
"""
|
|
1231
|
+
proper = get_proper_type(declared_type)
|
|
1232
|
+
if isinstance(proper, Instance):
|
|
1233
|
+
# If the attribute is already typed as an ORM descriptor/expression, keep it.
|
|
1234
|
+
if proper.type.fullname in SQLALCHEMY_INSTRUMENTED_ATTRIBUTE_FULLNAMES:
|
|
1235
|
+
return declared_type
|
|
1236
|
+
if proper.type.fullname == "sqlalchemy.orm.attributes.QueryableAttribute":
|
|
1237
|
+
return declared_type
|
|
1238
|
+
if proper.type.fullname in {
|
|
1239
|
+
"sqlalchemy.sql.elements.ColumnElement",
|
|
1240
|
+
"sqlalchemy.sql.expression.ColumnElement",
|
|
1241
|
+
}:
|
|
1242
|
+
return declared_type
|
|
1243
|
+
|
|
1244
|
+
# `Mapped[T]` is a descriptor; class-level access behaves like an instrumented attribute.
|
|
1245
|
+
if proper.type.fullname in SQLALCHEMY_MAPPED_FULLNAMES and proper.args:
|
|
1246
|
+
value_type = proper.args[0]
|
|
1247
|
+
inst = self._sqlalchemy_instrumented_attribute_type(api, value_type)
|
|
1248
|
+
if inst is not None:
|
|
1249
|
+
return inst
|
|
1250
|
+
|
|
1251
|
+
inst = self._sqlalchemy_instrumented_attribute_type(api, declared_type)
|
|
1252
|
+
if inst is not None:
|
|
1253
|
+
return inst
|
|
1254
|
+
col = self._sqlalchemy_column_element_type(api, declared_type)
|
|
1255
|
+
if col is not None:
|
|
1256
|
+
return col
|
|
1257
|
+
return declared_type
|
|
1258
|
+
|
|
1259
|
+
def _sqlmodel_getattr_return_type_callback(self, ctx: FunctionContext) -> Type:
|
|
1260
|
+
"""Type `getattr(Model, \"field\")` like `Model.field` for SQLModel table models."""
|
|
1261
|
+
if not ctx.args or len(ctx.args) < 2:
|
|
1262
|
+
return ctx.default_return_type
|
|
1263
|
+
if not ctx.args[0] or not ctx.args[1]:
|
|
1264
|
+
return ctx.default_return_type
|
|
1265
|
+
|
|
1266
|
+
name_expr = ctx.args[1][0]
|
|
1267
|
+
if not isinstance(name_expr, StrExpr):
|
|
1268
|
+
return ctx.default_return_type
|
|
1269
|
+
attr_name = name_expr.value
|
|
1270
|
+
|
|
1271
|
+
if not ctx.arg_types or not ctx.arg_types[0]:
|
|
1272
|
+
return ctx.default_return_type
|
|
1273
|
+
obj_type = get_proper_type(ctx.arg_types[0][0])
|
|
1274
|
+
|
|
1275
|
+
owner_info: TypeInfo | None = None
|
|
1276
|
+
if isinstance(obj_type, TypeType):
|
|
1277
|
+
item = get_proper_type(obj_type.item)
|
|
1278
|
+
if isinstance(item, Instance):
|
|
1279
|
+
owner_info = item.type
|
|
1280
|
+
if owner_info is None:
|
|
1281
|
+
return ctx.default_return_type
|
|
1282
|
+
if owner_info.fullname == SQLMODEL_BASEMODEL_FULLNAME:
|
|
1283
|
+
return ctx.default_return_type
|
|
1284
|
+
if not owner_info.has_base(SQLMODEL_BASEMODEL_FULLNAME):
|
|
1285
|
+
return ctx.default_return_type
|
|
1286
|
+
if not _is_table_model(owner_info):
|
|
1287
|
+
return ctx.default_return_type
|
|
1288
|
+
|
|
1289
|
+
# Mirror `Model.__table__` support.
|
|
1290
|
+
if attr_name == "__table__":
|
|
1291
|
+
return self._sqlalchemy_table_type(ctx.api)
|
|
1292
|
+
|
|
1293
|
+
# Prefer SQLModel members (fields/relationships): treat as SQLAlchemy expressions.
|
|
1294
|
+
var = self._lookup_var_in_mro(owner_info, attr_name)
|
|
1295
|
+
if (
|
|
1296
|
+
var is not None
|
|
1297
|
+
and var.type is not None
|
|
1298
|
+
and self._declares_sqlmodel_member(owner_info, attr_name)
|
|
1299
|
+
):
|
|
1300
|
+
tp = self._sqlalchemy_expr_type_for_class_attr(ctx.api, var.type)
|
|
1301
|
+
# For non-SQLModel attributes, only special-case SQLAlchemy expression-like descriptors.
|
|
1302
|
+
elif (
|
|
1303
|
+
var is not None and var.type is not None and self._is_sqlalchemy_expressionish(var.type)
|
|
1304
|
+
):
|
|
1305
|
+
tp = self._sqlalchemy_expr_type_for_class_attr(ctx.api, var.type)
|
|
1306
|
+
else:
|
|
1307
|
+
return ctx.default_return_type
|
|
1308
|
+
|
|
1309
|
+
# If a default value is provided, union it in (best-effort).
|
|
1310
|
+
if len(ctx.arg_types) >= 3 and ctx.arg_types[2]:
|
|
1311
|
+
default_t = ctx.arg_types[2][0]
|
|
1312
|
+
return UnionType.make_union([tp, default_t])
|
|
1313
|
+
return tp
|
|
1314
|
+
|
|
1315
|
+
def _sqlalchemy_mapped_type(self, api: Any, value_type: Type) -> Type | None:
|
|
1316
|
+
for fullname in ("sqlalchemy.orm.base.Mapped", "sqlalchemy.orm.Mapped"):
|
|
1317
|
+
try:
|
|
1318
|
+
return cast(Type, api.named_generic_type(fullname, [value_type]))
|
|
1319
|
+
except Exception:
|
|
1320
|
+
continue
|
|
1321
|
+
return None
|
|
1322
|
+
|
|
1323
|
+
def _sqlalchemy_column_property_return_type_callback(self, ctx: FunctionContext) -> Type:
|
|
1324
|
+
"""Best-effort typing for `sqlalchemy.orm.column_property(...)`.
|
|
1325
|
+
|
|
1326
|
+
This is primarily used to support patterns like:
|
|
1327
|
+
`Model._ticketscount = column_property(select(...).scalar_subquery())`
|
|
1328
|
+
where users later want `getattr(Model, "_ticketscount")` to behave like an ORM attribute.
|
|
1329
|
+
"""
|
|
1330
|
+
default = ctx.default_return_type
|
|
1331
|
+
if not ctx.arg_types or not ctx.arg_types[0]:
|
|
1332
|
+
return default
|
|
1333
|
+
|
|
1334
|
+
# If SQLAlchemy stubs already provide a precise mapped type, keep it.
|
|
1335
|
+
proper_default = get_proper_type(default)
|
|
1336
|
+
if (
|
|
1337
|
+
isinstance(proper_default, Instance)
|
|
1338
|
+
and proper_default.type.fullname in SQLALCHEMY_MAPPED_FULLNAMES
|
|
1339
|
+
and proper_default.args
|
|
1340
|
+
and not isinstance(get_proper_type(proper_default.args[0]), AnyType)
|
|
1341
|
+
):
|
|
1342
|
+
return default
|
|
1343
|
+
|
|
1344
|
+
# Infer `T` from the first argument type.
|
|
1345
|
+
arg0 = get_proper_type(ctx.arg_types[0][0])
|
|
1346
|
+
value_type: Type | None = None
|
|
1347
|
+
if isinstance(arg0, Instance) and arg0.args:
|
|
1348
|
+
if arg0.type.fullname in {
|
|
1349
|
+
"sqlalchemy.sql.selectable.ScalarSelect",
|
|
1350
|
+
"sqlalchemy.sql.expression.ScalarSelect",
|
|
1351
|
+
"sqlalchemy.sql.selectable.ScalarSubquery",
|
|
1352
|
+
}:
|
|
1353
|
+
value_type = arg0.args[0]
|
|
1354
|
+
if arg0.type.fullname in {
|
|
1355
|
+
"sqlalchemy.sql.elements.ColumnElement",
|
|
1356
|
+
"sqlalchemy.sql.expression.ColumnElement",
|
|
1357
|
+
}:
|
|
1358
|
+
value_type = arg0.args[0]
|
|
1359
|
+
|
|
1360
|
+
if value_type is None:
|
|
1361
|
+
return default
|
|
1362
|
+
|
|
1363
|
+
mapped = self._sqlalchemy_mapped_type(ctx.api, value_type)
|
|
1364
|
+
return mapped or default
|
|
1365
|
+
|
|
1162
1366
|
def _sqlmodel_model_class_callback(self, ctx: ClassDefContext) -> None:
|
|
1163
1367
|
# SQLModel is built on Pydantic v2; users commonly override `model_config`
|
|
1164
1368
|
# with `ConfigDict(...)`. SQLModel's own config typing may be narrower
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# ruff: noqa: B009
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import func
|
|
5
|
+
from sqlalchemy.orm import column_property, selectinload
|
|
6
|
+
from sqlmodel import Field, Relationship, SQLModel, select
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Team(SQLModel, table=True):
|
|
10
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
11
|
+
name: str = Field()
|
|
12
|
+
heroes: list[Hero] = Relationship(back_populates="team")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Hero(SQLModel, table=True):
|
|
16
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
17
|
+
name: str = Field()
|
|
18
|
+
team_id: int | None = Field(default=None, foreign_key="team.id")
|
|
19
|
+
team: Team | None = Relationship(back_populates="heroes")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
stmt_team = select(Team).options(selectinload(getattr(Team, "heroes")))
|
|
23
|
+
|
|
24
|
+
# `getattr(Model, "rel")` should be an ORM attribute (not the plain annotation type).
|
|
25
|
+
bad_heroes: list[Hero] = getattr(Team, "heroes")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class User(SQLModel, table=True):
|
|
29
|
+
id: int = Field(primary_key=True)
|
|
30
|
+
name: str = Field()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
stmt_user = select(User).where(getattr(User, "name").like("%x%"))
|
|
34
|
+
|
|
35
|
+
# `getattr(Model, "field")` should be a SQL expression (not the plain value type).
|
|
36
|
+
bad_id: int = getattr(User, "id")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Dynamic column_property assigned to a SQLModel class (common pattern for computed columns).
|
|
40
|
+
User._order_count = column_property( # noqa: SLF001
|
|
41
|
+
select(func.count(Hero.id)).where(Hero.team_id == Team.id).scalar_subquery()
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
stmt_count = select(User).where(getattr(User, "_order_count").between(1, 2))
|
|
45
|
+
bad_count: int = getattr(User, "_order_count")
|
|
@@ -53,6 +53,8 @@ cases: list[tuple[str, str]] = [
|
|
|
53
53
|
("mypy-plugin.ini", "relationship_comparators.py"),
|
|
54
54
|
("mypy-plugin.ini", "selectinload.py"),
|
|
55
55
|
("mypy-plugin-strict.ini", "selectinload.py"),
|
|
56
|
+
("mypy-plugin.ini", "getattr_column_property.py"),
|
|
57
|
+
("mypy-plugin-strict.ini", "getattr_column_property.py"),
|
|
56
58
|
("mypy-plugin.ini", "execution_options.py"),
|
|
57
59
|
("mypy-plugin-strict.ini", "execution_options.py"),
|
|
58
60
|
("mypy-plugin.ini", "aliases.py"),
|
|
@@ -17,6 +17,7 @@ from mypy.nodes import (
|
|
|
17
17
|
IfStmt,
|
|
18
18
|
MemberExpr,
|
|
19
19
|
NameExpr,
|
|
20
|
+
StrExpr,
|
|
20
21
|
SymbolTable,
|
|
21
22
|
SymbolTableNode,
|
|
22
23
|
TypeInfo,
|
|
@@ -905,6 +906,298 @@ def test_col_function_hook_returns_mapped_value_type() -> None:
|
|
|
905
906
|
assert t.args and isinstance(t.args[0], Instance) and t.args[0].type.fullname == "builtins.int"
|
|
906
907
|
|
|
907
908
|
|
|
909
|
+
def test_getattr_function_hook_types_sqlmodel_table_members() -> None:
|
|
910
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
911
|
+
api = DummyCheckerAPI()
|
|
912
|
+
|
|
913
|
+
model_info = make_sqlmodel_class("m.User")
|
|
914
|
+
model_info.defn.keywords["table"] = NameExpr("True")
|
|
915
|
+
model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {"id": {}}, "relationships": {}}
|
|
916
|
+
|
|
917
|
+
int_t = Instance(make_typeinfo("builtins.int"), [])
|
|
918
|
+
model_info.names["id"] = SymbolTableNode(0, Var("id", int_t))
|
|
919
|
+
|
|
920
|
+
hook = p.get_function_hook(plugin_mod.BUILTINS_GETATTR_FULLNAME)
|
|
921
|
+
assert hook is not None
|
|
922
|
+
|
|
923
|
+
user_type = TypeType(Instance(model_info, []))
|
|
924
|
+
ctx = FunctionContext(
|
|
925
|
+
arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])]],
|
|
926
|
+
arg_kinds=[[ARG_POS], [ARG_POS]],
|
|
927
|
+
callee_arg_names=["object", "name"],
|
|
928
|
+
arg_names=[[None], [None]],
|
|
929
|
+
default_return_type=AnyType(TypeOfAny.explicit),
|
|
930
|
+
args=[[NameExpr("User")], [StrExpr("id")]],
|
|
931
|
+
context=NameExpr("x"),
|
|
932
|
+
api=api,
|
|
933
|
+
)
|
|
934
|
+
t = hook(ctx)
|
|
935
|
+
assert isinstance(t, Instance)
|
|
936
|
+
assert t.type.fullname == "sqlalchemy.orm.attributes.InstrumentedAttribute"
|
|
937
|
+
assert t.args and isinstance(get_proper_type(t.args[0]), Instance)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def test_getattr_function_hook_handles_table_dunder_table() -> None:
|
|
941
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
942
|
+
api = DummyCheckerAPI()
|
|
943
|
+
|
|
944
|
+
model_info = make_sqlmodel_class("m.User")
|
|
945
|
+
model_info.defn.keywords["table"] = NameExpr("True")
|
|
946
|
+
model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {"id": {}}, "relationships": {}}
|
|
947
|
+
model_info.names["id"] = SymbolTableNode(
|
|
948
|
+
0, Var("id", Instance(make_typeinfo("builtins.int"), []))
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
user_type = TypeType(Instance(model_info, []))
|
|
952
|
+
ctx = FunctionContext(
|
|
953
|
+
arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])]],
|
|
954
|
+
arg_kinds=[[ARG_POS], [ARG_POS]],
|
|
955
|
+
callee_arg_names=["object", "name"],
|
|
956
|
+
arg_names=[[None], [None]],
|
|
957
|
+
default_return_type=AnyType(TypeOfAny.explicit),
|
|
958
|
+
args=[[NameExpr("User")], [StrExpr("__table__")]],
|
|
959
|
+
context=NameExpr("x"),
|
|
960
|
+
api=api,
|
|
961
|
+
)
|
|
962
|
+
t = p._sqlmodel_getattr_return_type_callback(ctx)
|
|
963
|
+
assert isinstance(t, Instance)
|
|
964
|
+
assert t.type.fullname == "sqlalchemy.sql.schema.Table"
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def test_getattr_function_hook_types_expressionish_non_member() -> None:
|
|
968
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
969
|
+
api = DummyCheckerAPI()
|
|
970
|
+
|
|
971
|
+
model_info = make_sqlmodel_class("m.User")
|
|
972
|
+
model_info.defn.keywords["table"] = NameExpr("True")
|
|
973
|
+
model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {}, "relationships": {}}
|
|
974
|
+
|
|
975
|
+
int_t = Instance(make_typeinfo("builtins.int"), [])
|
|
976
|
+
mapped_t = Instance(make_typeinfo("sqlalchemy.orm.base.Mapped"), [int_t])
|
|
977
|
+
model_info.names["_ticketscount"] = SymbolTableNode(0, Var("_ticketscount", mapped_t))
|
|
978
|
+
|
|
979
|
+
user_type = TypeType(Instance(model_info, []))
|
|
980
|
+
ctx = FunctionContext(
|
|
981
|
+
arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])]],
|
|
982
|
+
arg_kinds=[[ARG_POS], [ARG_POS]],
|
|
983
|
+
callee_arg_names=["object", "name"],
|
|
984
|
+
arg_names=[[None], [None]],
|
|
985
|
+
default_return_type=AnyType(TypeOfAny.explicit),
|
|
986
|
+
args=[[NameExpr("User")], [StrExpr("_ticketscount")]],
|
|
987
|
+
context=NameExpr("x"),
|
|
988
|
+
api=api,
|
|
989
|
+
)
|
|
990
|
+
t = p._sqlmodel_getattr_return_type_callback(ctx)
|
|
991
|
+
assert isinstance(t, Instance)
|
|
992
|
+
assert t.type.fullname == "sqlalchemy.orm.attributes.InstrumentedAttribute"
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def test_column_property_return_type_callback_infers_scalar_select() -> None:
|
|
996
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
997
|
+
api = DummyCheckerAPI()
|
|
998
|
+
|
|
999
|
+
int_t = Instance(make_typeinfo("builtins.int"), [])
|
|
1000
|
+
scalar_select_t = Instance(make_typeinfo("sqlalchemy.sql.selectable.ScalarSelect"), [int_t])
|
|
1001
|
+
ctx = FunctionContext(
|
|
1002
|
+
arg_types=[[scalar_select_t]],
|
|
1003
|
+
arg_kinds=[[ARG_OPT]],
|
|
1004
|
+
callee_arg_names=["column"],
|
|
1005
|
+
arg_names=[[None]],
|
|
1006
|
+
default_return_type=AnyType(TypeOfAny.explicit),
|
|
1007
|
+
args=[[NameExpr("x")]],
|
|
1008
|
+
context=NameExpr("x"),
|
|
1009
|
+
api=api,
|
|
1010
|
+
)
|
|
1011
|
+
t = p._sqlalchemy_column_property_return_type_callback(ctx)
|
|
1012
|
+
assert isinstance(t, Instance)
|
|
1013
|
+
assert t.type.fullname == "sqlalchemy.orm.base.Mapped"
|
|
1014
|
+
assert t.args and isinstance(get_proper_type(t.args[0]), Instance)
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def test_column_property_return_type_callback_infers_column_element() -> None:
|
|
1018
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
1019
|
+
api = DummyCheckerAPI()
|
|
1020
|
+
|
|
1021
|
+
int_t = Instance(make_typeinfo("builtins.int"), [])
|
|
1022
|
+
col_t = Instance(make_typeinfo("sqlalchemy.sql.elements.ColumnElement"), [int_t])
|
|
1023
|
+
ctx = FunctionContext(
|
|
1024
|
+
arg_types=[[col_t]],
|
|
1025
|
+
arg_kinds=[[ARG_OPT]],
|
|
1026
|
+
callee_arg_names=["column"],
|
|
1027
|
+
arg_names=[[None]],
|
|
1028
|
+
default_return_type=AnyType(TypeOfAny.explicit),
|
|
1029
|
+
args=[[NameExpr("x")]],
|
|
1030
|
+
context=NameExpr("x"),
|
|
1031
|
+
api=api,
|
|
1032
|
+
)
|
|
1033
|
+
t = p._sqlalchemy_column_property_return_type_callback(ctx)
|
|
1034
|
+
assert isinstance(t, Instance)
|
|
1035
|
+
assert t.type.fullname == "sqlalchemy.orm.base.Mapped"
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def test_column_property_return_type_callback_preserves_precise_mapped_default() -> None:
|
|
1039
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
1040
|
+
api = DummyCheckerAPI()
|
|
1041
|
+
|
|
1042
|
+
int_t = Instance(make_typeinfo("builtins.int"), [])
|
|
1043
|
+
scalar_select_t = Instance(make_typeinfo("sqlalchemy.sql.selectable.ScalarSelect"), [int_t])
|
|
1044
|
+
default_mapped = Instance(make_typeinfo("sqlalchemy.orm.base.Mapped"), [int_t])
|
|
1045
|
+
ctx = FunctionContext(
|
|
1046
|
+
arg_types=[[scalar_select_t]],
|
|
1047
|
+
arg_kinds=[[ARG_OPT]],
|
|
1048
|
+
callee_arg_names=["column"],
|
|
1049
|
+
arg_names=[[None]],
|
|
1050
|
+
default_return_type=default_mapped,
|
|
1051
|
+
args=[[NameExpr("x")]],
|
|
1052
|
+
context=NameExpr("x"),
|
|
1053
|
+
api=api,
|
|
1054
|
+
)
|
|
1055
|
+
assert p._sqlalchemy_column_property_return_type_callback(ctx) is default_mapped
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def test_getattr_return_type_callback_returns_default_for_missing_member() -> None:
|
|
1059
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
1060
|
+
api = DummyCheckerAPI()
|
|
1061
|
+
|
|
1062
|
+
model_info = make_sqlmodel_class("m.User")
|
|
1063
|
+
model_info.defn.keywords["table"] = NameExpr("True")
|
|
1064
|
+
model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {"id": {}}, "relationships": {}}
|
|
1065
|
+
model_info.names["id"] = SymbolTableNode(
|
|
1066
|
+
0, Var("id", Instance(make_typeinfo("builtins.int"), []))
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
user_type = TypeType(Instance(model_info, []))
|
|
1070
|
+
ctx = FunctionContext(
|
|
1071
|
+
arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])]],
|
|
1072
|
+
arg_kinds=[[ARG_POS], [ARG_POS]],
|
|
1073
|
+
callee_arg_names=["object", "name"],
|
|
1074
|
+
arg_names=[[None], [None]],
|
|
1075
|
+
default_return_type=AnyType(TypeOfAny.explicit),
|
|
1076
|
+
args=[[NameExpr("User")], [StrExpr("missing")]],
|
|
1077
|
+
context=NameExpr("x"),
|
|
1078
|
+
api=api,
|
|
1079
|
+
)
|
|
1080
|
+
assert p._sqlmodel_getattr_return_type_callback(ctx).type_of_any == TypeOfAny.explicit # type: ignore[attr-defined]
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def test_is_sqlalchemy_expressionish_covers_non_instance_and_other_fullnames() -> None:
|
|
1084
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
1085
|
+
|
|
1086
|
+
union = UnionType.make_union([Instance(make_typeinfo("builtins.int"), []), NoneType()])
|
|
1087
|
+
assert p._is_sqlalchemy_expressionish(union) is False
|
|
1088
|
+
|
|
1089
|
+
inst_attr = Instance(
|
|
1090
|
+
make_typeinfo("sqlalchemy.orm.attributes.InstrumentedAttribute"), [plugin_mod._plugin_any()]
|
|
1091
|
+
)
|
|
1092
|
+
assert p._is_sqlalchemy_expressionish(inst_attr) is True
|
|
1093
|
+
|
|
1094
|
+
queryable = Instance(
|
|
1095
|
+
make_typeinfo("sqlalchemy.orm.attributes.QueryableAttribute"), [plugin_mod._plugin_any()]
|
|
1096
|
+
)
|
|
1097
|
+
assert p._is_sqlalchemy_expressionish(queryable) is True
|
|
1098
|
+
|
|
1099
|
+
col = Instance(
|
|
1100
|
+
make_typeinfo("sqlalchemy.sql.elements.ColumnElement"), [plugin_mod._plugin_any()]
|
|
1101
|
+
)
|
|
1102
|
+
assert p._is_sqlalchemy_expressionish(col) is True
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def test_sqlalchemy_expr_type_for_class_attr_keeps_existing_expression_types() -> None:
|
|
1106
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
1107
|
+
api = DummyCheckerAPI()
|
|
1108
|
+
|
|
1109
|
+
inst_attr = Instance(
|
|
1110
|
+
make_typeinfo("sqlalchemy.orm.attributes.InstrumentedAttribute"), [plugin_mod._plugin_any()]
|
|
1111
|
+
)
|
|
1112
|
+
assert p._sqlalchemy_expr_type_for_class_attr(api, inst_attr) is inst_attr
|
|
1113
|
+
|
|
1114
|
+
queryable = Instance(
|
|
1115
|
+
make_typeinfo("sqlalchemy.orm.attributes.QueryableAttribute"), [plugin_mod._plugin_any()]
|
|
1116
|
+
)
|
|
1117
|
+
assert p._sqlalchemy_expr_type_for_class_attr(api, queryable) is queryable
|
|
1118
|
+
|
|
1119
|
+
col = Instance(
|
|
1120
|
+
make_typeinfo("sqlalchemy.sql.elements.ColumnElement"), [plugin_mod._plugin_any()]
|
|
1121
|
+
)
|
|
1122
|
+
assert p._sqlalchemy_expr_type_for_class_attr(api, col) is col
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def test_sqlalchemy_expr_type_for_class_attr_falls_back_to_column_element() -> None:
|
|
1126
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
1127
|
+
|
|
1128
|
+
class NoInstrumentedAPI(DummyCheckerAPI):
|
|
1129
|
+
def named_generic_type(self, name: str, args: list[Type]) -> Instance:
|
|
1130
|
+
if name in {
|
|
1131
|
+
"sqlalchemy.orm.attributes.InstrumentedAttribute",
|
|
1132
|
+
"sqlalchemy.orm.InstrumentedAttribute",
|
|
1133
|
+
}:
|
|
1134
|
+
raise RuntimeError("missing")
|
|
1135
|
+
return super().named_generic_type(name, args)
|
|
1136
|
+
|
|
1137
|
+
api = NoInstrumentedAPI()
|
|
1138
|
+
int_t = Instance(make_typeinfo("builtins.int"), [])
|
|
1139
|
+
t = p._sqlalchemy_expr_type_for_class_attr(api, int_t)
|
|
1140
|
+
assert isinstance(t, Instance)
|
|
1141
|
+
assert t.type.fullname == "sqlalchemy.sql.elements.ColumnElement"
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def test_getattr_return_type_callback_unions_default_value() -> None:
|
|
1145
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
1146
|
+
api = DummyCheckerAPI()
|
|
1147
|
+
|
|
1148
|
+
model_info = make_sqlmodel_class("m.User")
|
|
1149
|
+
model_info.defn.keywords["table"] = NameExpr("True")
|
|
1150
|
+
model_info.metadata[plugin_mod.METADATA_KEY] = {"fields": {"id": {}}, "relationships": {}}
|
|
1151
|
+
model_info.names["id"] = SymbolTableNode(
|
|
1152
|
+
0, Var("id", Instance(make_typeinfo("builtins.int"), []))
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
user_type = TypeType(Instance(model_info, []))
|
|
1156
|
+
ctx = FunctionContext(
|
|
1157
|
+
arg_types=[[user_type], [Instance(make_typeinfo("builtins.str"), [])], [NoneType()]],
|
|
1158
|
+
arg_kinds=[[ARG_POS], [ARG_POS], [ARG_OPT]],
|
|
1159
|
+
callee_arg_names=["object", "name", "default"],
|
|
1160
|
+
arg_names=[[None], [None], [None]],
|
|
1161
|
+
default_return_type=AnyType(TypeOfAny.explicit),
|
|
1162
|
+
args=[[NameExpr("User")], [StrExpr("id")], [NameExpr("None")]],
|
|
1163
|
+
context=NameExpr("x"),
|
|
1164
|
+
api=api,
|
|
1165
|
+
)
|
|
1166
|
+
t = p._sqlmodel_getattr_return_type_callback(ctx)
|
|
1167
|
+
assert isinstance(get_proper_type(t), UnionType)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def test_column_property_return_type_callback_guard_and_value_type_none_paths() -> None:
|
|
1171
|
+
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
1172
|
+
api = DummyCheckerAPI()
|
|
1173
|
+
|
|
1174
|
+
# No args -> no-op.
|
|
1175
|
+
ctx = FunctionContext(
|
|
1176
|
+
arg_types=[],
|
|
1177
|
+
arg_kinds=[],
|
|
1178
|
+
callee_arg_names=[],
|
|
1179
|
+
arg_names=[],
|
|
1180
|
+
default_return_type=AnyType(TypeOfAny.explicit),
|
|
1181
|
+
args=[],
|
|
1182
|
+
context=NameExpr("x"),
|
|
1183
|
+
api=api,
|
|
1184
|
+
)
|
|
1185
|
+
assert isinstance(p._sqlalchemy_column_property_return_type_callback(ctx), AnyType)
|
|
1186
|
+
|
|
1187
|
+
# Unknown arg type -> value_type stays None -> no-op.
|
|
1188
|
+
ctx = FunctionContext(
|
|
1189
|
+
arg_types=[[Instance(make_typeinfo("builtins.int"), [])]],
|
|
1190
|
+
arg_kinds=[[ARG_OPT]],
|
|
1191
|
+
callee_arg_names=["column"],
|
|
1192
|
+
arg_names=[[None]],
|
|
1193
|
+
default_return_type=AnyType(TypeOfAny.explicit),
|
|
1194
|
+
args=[[NameExpr("x")]],
|
|
1195
|
+
context=NameExpr("x"),
|
|
1196
|
+
api=api,
|
|
1197
|
+
)
|
|
1198
|
+
assert isinstance(p._sqlalchemy_column_property_return_type_callback(ctx), AnyType)
|
|
1199
|
+
|
|
1200
|
+
|
|
908
1201
|
def test_get_function_signature_hook_returns_none_for_missing_symbol() -> None:
|
|
909
1202
|
p = plugin_mod.SQLModelMypyPlugin(Options())
|
|
910
1203
|
p.lookup_fully_qualified = lambda _full: None # type: ignore[method-assign]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/mypy-plugin.ini
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/configs/pyproject-plugin.toml
RENAMED
|
File without changes
|
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/constructor.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/execution_options.py
RENAMED
|
File without changes
|
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/expressions.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/field_sa_type.py
RENAMED
|
File without changes
|
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/inheritance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/mapped_fields.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/model_config.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/model_construct.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/nullable_defaults.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/relationships.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/select_varargs.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/selectinload.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/session_exec.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/session_get.py
RENAMED
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/table_dunders.py
RENAMED
|
File without changes
|
|
File without changes
|
{sqlmodel_mypy_plugin-0.12.2 → sqlmodel_mypy_plugin-0.12.3}/tests/mypy/modules/untyped_fields.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|