Skip to content

Commit 27feebf

Browse files
committed
infer types for unannotated pytest fixture parameters
pytest binds a test/fixture parameter by name from the fixture registry, but only annotated parameters were checked against it — an unannotated one was left as Unknown. it now takes the fixture's provided type also extracts the parametrize marker recognition into pytest.rs so the check pass and the new inference share one definition of what a marker is
1 parent 56a9aa5 commit 27feebf

5 files changed

Lines changed: 457 additions & 49 deletions

File tree

crates/ty_python_semantic/resources/mdtest/external/pytest.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,63 @@ def test_add(a: int, b: int) -> None: ...
8383
@pytest.mark.parametrize("a, missing", [(1, 2)]) # error: [invalid-parametrize]
8484
def test_missing(a: int) -> None: ...
8585
```
86+
87+
## Unannotated parameters take the fixture's type
88+
89+
An unannotated parameter is bound by pytest to the fixture of that name, so it takes the fixture's
90+
provided type rather than staying gradual — for a user fixture, for a builtin like `tmp_path`, and
91+
across a `conftest.py`.
92+
93+
`conftest.py`:
94+
95+
```py
96+
import pytest
97+
98+
@pytest.fixture
99+
def db() -> str:
100+
return "sqlite://"
101+
```
102+
103+
`test_unannotated.py`:
104+
105+
```py
106+
import pytest
107+
from typing import Iterator
108+
109+
@pytest.fixture
110+
def port() -> int:
111+
return 5432
112+
113+
@pytest.fixture
114+
def handle() -> Iterator[bytes]:
115+
yield b""
116+
117+
def test_it(port, handle, db, tmp_path) -> None:
118+
reveal_type(port) # revealed: int
119+
reveal_type(handle) # revealed: bytes
120+
reveal_type(db) # revealed: str
121+
reveal_type(tmp_path) # revealed: Path
122+
# the injected type is usable: a real attribute resolves, a bogus one is an error
123+
reveal_type(tmp_path.name) # revealed: str
124+
port.bit_length()
125+
port.no_such_method # error: [unresolved-attribute]
126+
```
127+
128+
## Parametrized names are arguments, not fixtures
129+
130+
A name `@pytest.mark.parametrize` supplies comes from the marker's value rows, not the registry, so
131+
it is not fixture-typed even when a fixture of that name exists.
132+
133+
`test_parametrized.py`:
134+
135+
```py
136+
import pytest
137+
138+
@pytest.fixture
139+
def value() -> int:
140+
return 1
141+
142+
@pytest.mark.parametrize("value", ["a", "b"])
143+
def test_it(value) -> None:
144+
reveal_type(value) # revealed: Unknown
145+
```

crates/ty_python_semantic/resources/mdtest/pytest.md

Lines changed: 269 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ fixtures and `parametrize` — against the real package.
88
pytest fills a test or fixture parameter by *name* from a registry: the function's own module, then
99
the `conftest.py` chain, then builtin fixtures. A parameter annotation that disagrees with the
1010
resolved fixture's type is an error; the fixture's provided type is its return annotation, with a
11-
yield fixture's `Iterator[T]` / `Generator[T, ...]` unwrapped to `T`.
11+
yield fixture's `Iterator[T]` / `Generator[T, ...]` unwrapped to `T`. An *unannotated* parameter
12+
takes that provided type instead of the implicit `Unknown` an ordinary parameter would get.
1213

1314
## Fixture resolution and type checking
1415

@@ -199,3 +200,270 @@ def fixture(function: None = ..., *, scope: str = ..., name: str | None = None)
199200
def test_it(missing) -> None: # error: [unknown-fixture]
200201
...
201202
```
203+
204+
## Unannotated parameters take the fixture's type
205+
206+
An unannotated parameter is bound by pytest to the fixture of the same name, so the body sees that
207+
fixture's provided type — including through a rename, a yield fixture's unwrapping, and the
208+
`conftest.py` chain. A name that resolves to no fixture stays gradual, as does one whose fixture has
209+
no derivable type.
210+
211+
```toml
212+
[environment]
213+
python = "/.venv"
214+
```
215+
216+
`/.venv/<path-to-site-packages>/pytest/__init__.pyi`:
217+
218+
```pyi
219+
from _pytest.fixtures import fixture as fixture
220+
```
221+
222+
`/.venv/<path-to-site-packages>/_pytest/__init__.pyi`:
223+
224+
```pyi
225+
```
226+
227+
`/.venv/<path-to-site-packages>/_pytest/fixtures.pyi`:
228+
229+
```pyi
230+
from typing import Any, Callable, overload
231+
232+
class FixtureFunctionDefinition: ...
233+
234+
class FixtureFunctionMarker:
235+
def __call__(self, function: Callable[..., Any]) -> FixtureFunctionDefinition: ...
236+
237+
@overload
238+
def fixture(function: Callable[..., Any], *, scope: str = ..., name: str | None = None) -> FixtureFunctionDefinition: ...
239+
@overload
240+
def fixture(function: None = ..., *, scope: str = ..., name: str | None = None) -> FixtureFunctionMarker: ...
241+
```
242+
243+
`conftest.py`:
244+
245+
```py
246+
import pytest
247+
248+
@pytest.fixture
249+
def from_conftest() -> bytes:
250+
return b""
251+
```
252+
253+
`test_unannotated.py`:
254+
255+
```py
256+
import pytest
257+
from typing import Iterator
258+
259+
@pytest.fixture
260+
def number() -> int:
261+
return 1
262+
263+
@pytest.fixture(name="renamed")
264+
def _make_text() -> str:
265+
return "x"
266+
267+
@pytest.fixture
268+
def yielded() -> Iterator[bool]:
269+
yield True
270+
271+
@pytest.fixture
272+
def untyped():
273+
return object()
274+
275+
def test_it(number, renamed, yielded, from_conftest, untyped, missing) -> None: # error: [unknown-fixture]
276+
reveal_type(number) # revealed: int
277+
reveal_type(renamed) # revealed: str
278+
reveal_type(yielded) # revealed: bool
279+
reveal_type(from_conftest) # revealed: bytes
280+
reveal_type(untyped) # revealed: Unknown
281+
reveal_type(missing) # revealed: Unknown
282+
```
283+
284+
## A fixture's own parameters take fixture types
285+
286+
Fixtures request fixtures the same way tests do, so an unannotated parameter of a `@pytest.fixture`
287+
function is typed from the registry too — including when the fixture it requests is defined later in
288+
the module.
289+
290+
```toml
291+
[environment]
292+
python = "/.venv"
293+
```
294+
295+
`/.venv/<path-to-site-packages>/pytest/__init__.pyi`:
296+
297+
```pyi
298+
from _pytest.fixtures import fixture as fixture
299+
```
300+
301+
`/.venv/<path-to-site-packages>/_pytest/__init__.pyi`:
302+
303+
```pyi
304+
```
305+
306+
`/.venv/<path-to-site-packages>/_pytest/fixtures.pyi`:
307+
308+
```pyi
309+
from typing import Any, Callable, overload
310+
311+
class FixtureFunctionDefinition: ...
312+
313+
class FixtureFunctionMarker:
314+
def __call__(self, function: Callable[..., Any]) -> FixtureFunctionDefinition: ...
315+
316+
@overload
317+
def fixture(function: Callable[..., Any], *, scope: str = ..., name: str | None = None) -> FixtureFunctionDefinition: ...
318+
@overload
319+
def fixture(function: None = ..., *, scope: str = ..., name: str | None = None) -> FixtureFunctionMarker: ...
320+
```
321+
322+
`test_chained.py`:
323+
324+
```py
325+
import pytest
326+
327+
@pytest.fixture
328+
def outer(inner) -> str:
329+
reveal_type(inner) # revealed: int
330+
return str(inner)
331+
332+
@pytest.fixture
333+
def inner() -> int:
334+
return 1
335+
336+
def test_it(outer) -> None:
337+
reveal_type(outer) # revealed: str
338+
```
339+
340+
## Parametrized names are arguments, not fixtures
341+
342+
`@pytest.mark.parametrize` supplies a name from its value rows rather than from the fixture
343+
registry, so a parametrized parameter is not fixture-typed even when a fixture of that name exists.
344+
345+
```toml
346+
[environment]
347+
python = "/.venv"
348+
```
349+
350+
`/.venv/<path-to-site-packages>/pytest/__init__.pyi`:
351+
352+
```pyi
353+
from _pytest.fixtures import fixture as fixture
354+
from _pytest.mark.structures import MarkGenerator as MarkGenerator
355+
356+
mark: MarkGenerator
357+
```
358+
359+
`/.venv/<path-to-site-packages>/_pytest/__init__.pyi`:
360+
361+
```pyi
362+
```
363+
364+
`/.venv/<path-to-site-packages>/_pytest/mark/__init__.pyi`:
365+
366+
```pyi
367+
```
368+
369+
`/.venv/<path-to-site-packages>/_pytest/mark/structures.pyi`:
370+
371+
```pyi
372+
from typing import Any
373+
374+
class MarkDecorator:
375+
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
376+
377+
class MarkGenerator:
378+
def __getattr__(self, name: str) -> MarkDecorator: ...
379+
```
380+
381+
`/.venv/<path-to-site-packages>/_pytest/fixtures.pyi`:
382+
383+
```pyi
384+
from typing import Any, Callable, overload
385+
386+
class FixtureFunctionDefinition: ...
387+
388+
class FixtureFunctionMarker:
389+
def __call__(self, function: Callable[..., Any]) -> FixtureFunctionDefinition: ...
390+
391+
@overload
392+
def fixture(function: Callable[..., Any], *, scope: str = ..., name: str | None = None) -> FixtureFunctionDefinition: ...
393+
@overload
394+
def fixture(function: None = ..., *, scope: str = ..., name: str | None = None) -> FixtureFunctionMarker: ...
395+
```
396+
397+
`test_parametrized.py`:
398+
399+
```py
400+
import pytest
401+
402+
@pytest.fixture
403+
def value() -> int:
404+
return 1
405+
406+
@pytest.mark.parametrize("value", ["a", "b"])
407+
def test_it(value) -> None:
408+
# supplied by the marker, so the same-named fixture does not apply
409+
reveal_type(value) # revealed: Unknown
410+
411+
@pytest.mark.parametrize("other", ["a", "b"])
412+
def test_mixed(other, value) -> None:
413+
reveal_type(other) # revealed: Unknown
414+
reveal_type(value) # revealed: int
415+
```
416+
417+
## Ordinary functions are untouched
418+
419+
A function pytest does not manage keeps the ordinary rules for an unannotated parameter, even when a
420+
fixture of that name is in scope.
421+
422+
```toml
423+
[environment]
424+
python = "/.venv"
425+
```
426+
427+
`/.venv/<path-to-site-packages>/pytest/__init__.pyi`:
428+
429+
```pyi
430+
from _pytest.fixtures import fixture as fixture
431+
```
432+
433+
`/.venv/<path-to-site-packages>/_pytest/__init__.pyi`:
434+
435+
```pyi
436+
```
437+
438+
`/.venv/<path-to-site-packages>/_pytest/fixtures.pyi`:
439+
440+
```pyi
441+
from typing import Any, Callable, overload
442+
443+
class FixtureFunctionDefinition: ...
444+
445+
class FixtureFunctionMarker:
446+
def __call__(self, function: Callable[..., Any]) -> FixtureFunctionDefinition: ...
447+
448+
@overload
449+
def fixture(function: Callable[..., Any], *, scope: str = ..., name: str | None = None) -> FixtureFunctionDefinition: ...
450+
@overload
451+
def fixture(function: None = ..., *, scope: str = ..., name: str | None = None) -> FixtureFunctionMarker: ...
452+
```
453+
454+
`test_ordinary.py`:
455+
456+
```py
457+
import pytest
458+
459+
@pytest.fixture
460+
def number() -> int:
461+
return 1
462+
463+
# a helper, not a test: pytest never calls it, so `number` is an ordinary parameter
464+
def helper(number) -> None:
465+
reveal_type(number) # revealed: Unknown
466+
467+
def test_it() -> None:
468+
helper("anything")
469+
```

0 commit comments

Comments
 (0)