Skip to content

Commit cf141f6

Browse files
authored
Merge pull request #46 from George-Ogden/autouse-fixtures
Autouse Fixtures
2 parents 8cc7b93 + d42cdac commit cf141f6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1369
-395
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ See the [Mypy docs](https://mypy.readthedocs.io/en/stable/extending_mypy.html#co
5656
The Mypy plugin system is fairly limited, so this can only check marked functions.
5757
If you're using parametrized testing, that's fine as you `pytest.mark.parametrize`.
5858
If not, [add a `typed` mark](https://docs.pytest.org/en/stable/how-to/mark.html#registering-marks) then mark any remaining tests you want to check.
59+
The order of the error messages is unclear, but this isn't an issue if you're using a plugin.
5960

6061
```python
6162
import random

mypy_pytest_plugin/argmapper.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,13 @@ def _named_arg_overloaded_mapping(
7474
@classmethod
7575
def _merge_mappings(cls, this: ArgMap, that: ArgMap) -> ArgMap:
7676
return {key: expr for key, expr in this.items() if that.get(key, None) is expr}
77+
78+
@classmethod
79+
def named_arg(cls, call: CallExpr, name: str) -> Expression | None:
80+
expressions = [
81+
arg for arg_name, arg in zip(call.arg_names, call.args, strict=True) if arg_name == name
82+
]
83+
match expressions:
84+
case [expression]:
85+
return expression
86+
return None

mypy_pytest_plugin/argnames_parser.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@
2020
UNREADABLE_ARGNAME,
2121
UNREADABLE_ARGNAMES,
2222
)
23+
from .utils import cache_by_id
2324

2425

2526
@dataclass(frozen=True)
2627
class ArgnamesParser(CheckerWrapper):
2728
checker: TypeChecker
2829

30+
def __post_init__(self) -> None:
31+
object.__setattr__(self, "parse_names", cache_by_id(self.parse_names))
32+
2933
def parse_names(self, expression: Expression) -> str | list[str] | None:
3034
match expression:
3135
case StrExpr():

mypy_pytest_plugin/checker_wrapper.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ class CheckerWrapper(abc.ABC):
1717
@abc.abstractmethod
1818
def __init__(self) -> None: ...
1919

20-
def fail(self, msg: str, *, context: Context, code: ErrorCode) -> None:
21-
self.checker.fail(msg, context=context, code=code)
20+
def fail(self, msg: str, *, context: Context, code: ErrorCode, file: None | str = None) -> None:
21+
self.checker.msg.fail(msg, context=context, code=code, file=file)
2222

2323
def note(self, msg: str, *, context: Context, code: ErrorCode | None) -> None:
2424
self.checker.note(msg, context=context, code=code)

mypy_pytest_plugin/decorator_wrapper.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Sequence
22
from dataclasses import dataclass
3+
import functools
34
from typing import Self, TypeGuard
45

56
from mypy.checker import TypeChecker
@@ -38,7 +39,7 @@ def _is_parametrized_decorator_expr(
3839
)
3940
return False
4041

41-
@property
42+
@functools.cached_property
4243
def arg_names_and_arg_values(self) -> tuple[Expression, Expression] | None:
4344
name_mapping = ArgMapper.named_arg_mapping(self.call, self.checker)
4445
try:
@@ -50,3 +51,10 @@ def arg_names_and_arg_values(self) -> tuple[Expression, Expression] | None:
5051
code=UNREADABLE_ARGNAMES_ARGVALUES,
5152
)
5253
return None
54+
55+
@property
56+
def arg_names(self) -> Expression | None:
57+
if self.arg_names_and_arg_values is None:
58+
return None
59+
arg_names, _arg_values = self.arg_names_and_arg_values
60+
return arg_names

mypy_pytest_plugin/defer.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,14 @@
1-
class DeferralError(Exception): ...
1+
from dataclasses import dataclass
2+
import enum
3+
4+
5+
class DeferralReason(enum.IntEnum):
6+
# cannot compute value until another node has been processed
7+
REQUIRED_WAIT = enum.auto()
8+
# requires other nodes in the file to be processed to ensure correctness
9+
SPECULATIVE_WAIT = enum.auto()
10+
11+
12+
@dataclass(eq=False)
13+
class DeferralError(Exception):
14+
cause: DeferralReason

mypy_pytest_plugin/error_codes.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,6 @@
2424
"duplicate-argname", "Pytest parametrize contains duplicate argnames.", category="Pytest"
2525
)
2626

27-
REPEATED_ARGNAME: Final[ErrorCode] = ErrorCode(
28-
"repeated-argname", "Pytest parametrizations contain repeated argname.", category="Pytest"
29-
)
30-
3127

3228
REPEATED_FIXTURE_ARGNAME: Final[ErrorCode] = ErrorCode(
3329
"repeated-fixture-argname",
@@ -89,6 +85,12 @@
8985
"Do not use `pytest.mark` with `pytest.fixture`.",
9086
category="Pytest",
9187
)
88+
INVALID_FIXTURE_AUTOUSE: Final[ErrorCode] = ErrorCode(
89+
"invalid-fixture-autouse",
90+
"Use literal value when setting autouse of a Pytest fixture.",
91+
category="Pytest",
92+
)
93+
9294

9395
INVALID_FIXTURE_SCOPE: Final[ErrorCode] = ErrorCode(
9496
"invalid-fixture-scope",

mypy_pytest_plugin/excluded_test_checker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from mypy.subtypes import is_same_type
1313
from mypy.types import LiteralType
1414

15-
from .defer import DeferralError
15+
from .defer import DeferralError, DeferralReason
1616

1717

1818
class ExcludedTestChecker:
@@ -46,7 +46,7 @@ def _identify_non_test_assignment_names(
4646
) -> Iterable[str]:
4747
rvalue_type = checker.lookup_type_or_none(assignment.rvalue)
4848
if rvalue_type is None:
49-
raise DeferralError()
49+
raise DeferralError(DeferralReason.REQUIRED_WAIT)
5050
if is_same_type(rvalue_type, LiteralType(False, checker.named_type("builtins.bool"))):
5151
for lvalue in assignment.lvalues:
5252
assignment_target = cls._test_assignment_target(lvalue)

mypy_pytest_plugin/fixture.py

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@
66
from typing import ClassVar, Final, Self, cast
77

88
from mypy.checker import TypeChecker
9-
from mypy.nodes import CallExpr, Context, Decorator, Expression, FuncDef
9+
from mypy.nodes import (
10+
GDEF,
11+
CallExpr,
12+
Context,
13+
Decorator,
14+
Expression,
15+
FuncDef,
16+
SymbolTableNode,
17+
Var,
18+
)
1019
from mypy.subtypes import is_subtype
1120
from mypy.types import (
1221
AnyType,
@@ -17,14 +26,23 @@
1726
Type,
1827
TypeOfAny,
1928
TypeVarLikeType,
29+
UnionType,
2030
)
2131

32+
from .argmapper import ArgMapper
2233
from .checker_wrapper import CheckerWrapper
23-
from .defer import DeferralError
24-
from .error_codes import DUPLICATE_FIXTURE, INVALID_FIXTURE_SCOPE, MARKED_FIXTURE, REQUEST_KEYWORD
34+
from .defer import DeferralError, DeferralReason
35+
from .error_codes import (
36+
DUPLICATE_FIXTURE,
37+
INVALID_FIXTURE_AUTOUSE,
38+
INVALID_FIXTURE_SCOPE,
39+
MARKED_FIXTURE,
40+
REQUEST_KEYWORD,
41+
)
2542
from .fullname import Fullname
2643
from .test_argument import TestArgument
2744
from .types_module import TYPES_MODULE
45+
from .utils import strict_cast, strict_not_none
2846

2947
FixtureScope = enum.IntEnum(
3048
"FixtureScope", ["function", "class", "module", "package", "session", "unknown"]
@@ -34,11 +52,13 @@
3452

3553
@dataclass(frozen=True, slots=True, kw_only=True)
3654
class Fixture:
55+
AUTOUSE_NAME: ClassVar[str] = "__autouse__"
3756
fullname: Fullname
3857
file: str
3958
return_type: Type
4059
arguments: Sequence[TestArgument]
4160
scope: FixtureScope
61+
autouse: bool
4262
type_variables: Sequence[TypeVarLikeType]
4363
context: Context
4464

@@ -54,13 +74,15 @@ def from_type(
5474
scope: FixtureScope,
5575
file: str,
5676
is_generator: bool,
77+
autouse: bool,
5778
fullname: str,
5879
) -> Self:
5980
func = type.definition
6081
assert isinstance(func, FuncDef | None)
6182
if isinstance(func, FuncDef):
62-
arguments = TestArgument.from_fn_def(func, checker=None, source="fixture")
63-
assert arguments is not None
83+
arguments = strict_not_none(
84+
TestArgument.from_fn_def(func, checker=None, source="fixture")
85+
)
6486
context: Context = func
6587
else:
6688
arguments = TestArgument.from_type(type)
@@ -71,6 +93,7 @@ def from_type(
7193
return_type=FixtureParser.fixture_return_type(type.ret_type, is_generator=is_generator),
7294
arguments=arguments,
7395
scope=scope,
96+
autouse=autouse,
7497
context=context,
7598
type_variables=type.variables,
7699
)
@@ -97,6 +120,8 @@ def module_name(self) -> Fullname:
97120

98121
def as_fixture_type(self, *, decorator: Decorator, checker: TypeChecker) -> Type:
99122
assert decorator.func.type is not None
123+
if self.autouse:
124+
self.save_to_autouse(checker)
100125
return checker.named_generic_type(
101126
f"{TYPES_MODULE}.FixtureType",
102127
[
@@ -106,9 +131,37 @@ def as_fixture_type(self, *, decorator: Decorator, checker: TypeChecker) -> Type
106131
decorator.func.is_generator, fallback=checker.named_type("builtins.object")
107132
),
108133
LiteralType(decorator.fullname, fallback=checker.named_type("builtins.object")),
134+
LiteralType(self.autouse, fallback=checker.named_type("builtins.object")),
109135
],
110136
)
111137

138+
def save_to_autouse(self, checker: TypeChecker) -> None:
139+
if str(self.module_name) in checker.modules:
140+
node = checker.modules[str(self.module_name)].names.setdefault(
141+
self.AUTOUSE_NAME,
142+
SymbolTableNode(
143+
GDEF,
144+
Var(
145+
self.AUTOUSE_NAME,
146+
UnionType([]),
147+
),
148+
implicit=True,
149+
module_hidden=True,
150+
plugin_generated=True,
151+
),
152+
)
153+
literal_type = LiteralType(
154+
self.name,
155+
fallback=checker.named_type("builtins.str"),
156+
)
157+
assert isinstance(node.node, Var)
158+
assert isinstance(node.type, UnionType)
159+
if not any(
160+
strict_cast(LiteralType, item).value == literal_type.value
161+
for item in node.type.items
162+
):
163+
node.type.items.append(literal_type)
164+
112165

113166
@dataclass(frozen=True, slots=True)
114167
class FixtureParser(CheckerWrapper):
@@ -134,6 +187,7 @@ def from_decorator(self, decorator: Decorator) -> Fixture | None:
134187
),
135188
arguments=arguments,
136189
scope=self._fixture_scope_from_decorator(fixture_decorator),
190+
autouse=self._fixture_autouse_from_decorator(fixture_decorator),
137191
context=decorator.func,
138192
type_variables=type_.variables,
139193
)
@@ -210,7 +264,7 @@ def _warn_extra_decorator(self, decorator: Expression) -> None:
210264
def _is_fixture_decorator(self, decorator: Expression) -> bool:
211265
decorator_type = self.checker.lookup_type_or_none(decorator)
212266
if decorator_type is None:
213-
raise DeferralError()
267+
raise DeferralError(DeferralReason.REQUIRED_WAIT)
214268
return self._is_fixture_type(decorator_type) or (
215269
isinstance(decorator_type, Overloaded)
216270
and any(self._is_fixture_type(overload.ret_type) for overload in decorator_type.items)
@@ -229,12 +283,9 @@ def _fixture_scope_from_decorator(self, decorator: Expression) -> FixtureScope:
229283
return DEFAULT_SCOPE
230284

231285
def _fixture_scope_from_call(self, call: CallExpr) -> FixtureScope:
232-
scope_expressions = [
233-
arg for name, arg in zip(call.arg_names, call.args, strict=True) if name == "scope"
234-
]
235-
if not scope_expressions:
286+
scope_expression = ArgMapper.named_arg(call, "scope")
287+
if scope_expression is None:
236288
return DEFAULT_SCOPE
237-
[scope_expression] = scope_expressions
238289
return self._fixture_scope_from_type(
239290
self.checker.lookup_type(scope_expression), context=scope_expression
240291
)
@@ -250,6 +301,39 @@ def _fixture_scope_from_type(self, type_: Type, context: Context) -> FixtureScop
250301

251302
return FixtureScope.unknown
252303

304+
def _fixture_autouse_from_decorator(self, decorator: Expression) -> bool:
305+
if isinstance(decorator, CallExpr):
306+
return self._fixture_autouse_from_call(decorator)
307+
return False
308+
309+
def _fixture_autouse_from_call(self, call: CallExpr) -> bool:
310+
autouse_expression = ArgMapper.named_arg(call, "autouse")
311+
if autouse_expression is None:
312+
return False
313+
return self._fixture_autouse_from_type(
314+
self.checker.lookup_type(autouse_expression), context=autouse_expression
315+
)
316+
317+
def _fixture_autouse_from_type(self, type_: Type, context: Context) -> bool:
318+
for value in [True, False]:
319+
if is_subtype(
320+
type_, LiteralType(value, fallback=self.checker.named_type("builtins.bool"))
321+
):
322+
return value
323+
if isinstance(type_, LiteralType) and isinstance(type_.value, bool):
324+
return type_.value
325+
self.fail(
326+
"""Invalid type for "autouse". This fixture will be not be applied automatically when type checking.""",
327+
context=context,
328+
code=INVALID_FIXTURE_AUTOUSE,
329+
)
330+
self.note(
331+
"Use `autouse=True` directly.",
332+
context=context,
333+
code=INVALID_FIXTURE_AUTOUSE,
334+
)
335+
return False
336+
253337
def is_request_name(self, decorator: Decorator) -> bool:
254338
if is_request_name := decorator.name == "request":
255339
self.fail(

0 commit comments

Comments
 (0)