Skip to content

Commit 9ef0f90

Browse files
authored
Merge pull request #35 from George-Ogden/unknown-mock
Add Error When Mocked Location is Unknown
2 parents 36d1f9e + 117edba commit 9ef0f90

File tree

8 files changed

+39
-22
lines changed

8 files changed

+39
-22
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ A Mypy plugin for type checking Pytest code.
1414
- [x] All the fixture arguments are included
1515
- [x] You don't have conflicting fixtures
1616
- Checks your mocks
17+
- [x] You're mocking something that exists
1718
- [x] Your mock has the correct type (or close enough)
1819
- Checks your marks
1920
- [x] You're using a pre-defined mark
@@ -44,7 +45,7 @@ plugins = ["mypy_pytest_plugin.plugin"]
4445

4546
`mypy.ini`:
4647

47-
```toml
48+
```ini
4849
plugins = mypy_pytest_plugin.plugin
4950
```
5051

mypy_pytest_plugin/object_patch_call_checker.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from dataclasses import dataclass
22

33
from mypy.checker import TypeChecker
4-
from mypy.nodes import CallExpr, Expression, MemberExpr
4+
from mypy.nodes import CallExpr, Context, Expression, MemberExpr
55
from mypy.types import AnyType, Type
66

77
from .argmapper import ArgMapper
@@ -17,18 +17,24 @@ def add_patch_generics(self, call: CallExpr) -> Type | None:
1717
(target_arg := self._target_arg(call)) is not None
1818
and (attribute_arg := self._attribute_arg(call)) is not None
1919
and (attribute_value := self._string_value(attribute_arg)) is not None
20-
and (original_type := self._attribute_type(target_arg, attribute_value)) is not None
20+
and (
21+
original_type := self._attribute_type(
22+
target_arg, attribute_value, context=attribute_arg
23+
)
24+
)
25+
is not None
2126
):
2227
return self._specialized_patcher_type(original_type, attribute="object")
2328
return None
2429

2530
def _attribute_arg(self, call: CallExpr) -> Expression | None:
2631
return ArgMapper.named_arg_mapping(call, self.checker).get("attribute")
2732

28-
def _attribute_type(self, base: Expression, attribute: str) -> Type | None:
29-
type_ = self.checker.expr_checker.analyze_ordinary_member_access(
30-
MemberExpr(base, name=attribute), is_lvalue=False
31-
)
33+
def _attribute_type(self, base: Expression, attribute: str, *, context: Context) -> Type | None:
34+
member = MemberExpr(base, name=attribute)
35+
member.line = context.line
36+
member.column = context.column
37+
type_ = self.checker.expr_checker.analyze_ordinary_member_access(member, is_lvalue=False)
3238
if isinstance(type_, AnyType) and type_.is_from_error:
3339
return None
3440
return type_

mypy_pytest_plugin/object_patch_call_checker_test.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from mypy.subtypes import is_same_type
33

44
from .object_patch_call_checker import ObjectPatchCallChecker
5-
from .test_utils import dump_expr, parse
5+
from .test_utils import check_error_messages, dump_expr, get_error_messages, parse
66

77

88
def _attribute_arg_test_body(defs: str) -> None:
@@ -65,14 +65,17 @@ def foo[T](target: Any, attribute: str, test: T, *, extra_arg: int = 0) -> int:
6565
)
6666

6767

68-
def _attribute_type_test_body(defs: str, attribute: str) -> None:
68+
def _attribute_type_test_body(
69+
defs: str, attribute: str, *, errors: None | list[str] = None
70+
) -> None:
6971
parse_result = parse(defs)
70-
patch_call_checker = ObjectPatchCallChecker(parse_result.checker)
72+
checker = parse_result.checker
73+
patch_call_checker = ObjectPatchCallChecker(checker)
7174

7275
base = parse_result.defs["base"]
7376
assert isinstance(base, Expression)
7477

75-
attribute_type = patch_call_checker._attribute_type(base, attribute)
78+
attribute_type = patch_call_checker._attribute_type(base, attribute, context=base)
7679

7780
expected = parse_result.types.get("expected")
7881
if expected is None:
@@ -81,6 +84,9 @@ def _attribute_type_test_body(defs: str, attribute: str) -> None:
8184
assert attribute_type is not None
8285
assert is_same_type(attribute_type, expected)
8386

87+
error_messages = get_error_messages(checker)
88+
check_error_messages(error_messages, errors=errors)
89+
8490

8591
def test_attribute_type_simple_class() -> None:
8692
_attribute_type_test_body(
@@ -116,6 +122,7 @@ def test_attribute_type_invalid_base() -> None:
116122
base = None
117123
""",
118124
"attribute",
125+
errors=["attr-defined"],
119126
)
120127

121128

@@ -128,6 +135,7 @@ class Base:
128135
base = Base()
129136
""",
130137
"not_an_attribute",
138+
errors=["attr-defined"],
131139
)
132140

133141

mypy_pytest_plugin/patch_call_checker.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,9 @@
22
from typing import Any
33

44
from mypy.checker import TypeChecker
5+
from mypy.errorcodes import NAME_DEFINED
56
from mypy.expandtype import expand_type_by_instance
6-
from mypy.nodes import (
7-
CallExpr,
8-
Expression,
9-
MypyFile,
10-
StrExpr,
11-
)
7+
from mypy.nodes import CallExpr, Context, Expression, MypyFile, StrExpr
128
from mypy.types import (
139
CallableType,
1410
Instance,
@@ -32,7 +28,7 @@ def add_patch_generics(self, call: CallExpr) -> Type | None:
3228
if (
3329
(target_arg := self._target_arg(call)) is not None
3430
and (arg_value := self._string_value(target_arg)) is not None
35-
and (original_type := self._lookup_fullname_type(arg_value))
31+
and (original_type := self._lookup_fullname_type(arg_value, context=target_arg))
3632
):
3733
return self._specialized_patcher_type(original_type)
3834
return None
@@ -49,7 +45,7 @@ def _string_value(self, expression: Expression) -> str | None:
4945
return literal_type.value
5046
return None
5147

52-
def _lookup_fullname_type(self, fullname: str) -> Type | None:
48+
def _lookup_fullname_type(self, fullname: str, *, context: Context) -> Type | None:
5349
module_name, target = Fullname.from_string(fullname), Fullname(())
5450
while module_name:
5551
if (module := self.checker.modules.get(str(module_name))) and (
@@ -58,6 +54,7 @@ def _lookup_fullname_type(self, fullname: str) -> Type | None:
5854
return type_
5955
target = target.push_front(module_name.name)
6056
module_name = module_name.module_name
57+
self.checker.fail(f"{fullname!r} does not exist.", context=context, code=NAME_DEFINED)
6158
return None
6259

6360
def _lookup_fullname_type_in_module(self, module: MypyFile, target: Fullname) -> Type | None:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "mypy-pytest-plugin"
33
requires-python = ">=3.12,<3.15"
4-
version = "1.0.0"
4+
version = "1.1.0"
55
dynamic = ["dependencies"]
66

77
[tool.setuptools]

test_samples/patch_object_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,6 @@ def prop(self) -> bool:
4141
mock.patch.object(PropertyClass(), "prop", False)
4242
mock.patch.object(PropertyClass(), "prop", mock.PropertyMock(2.0))
4343
mock.patch.object(PropertyClass(), "prop", mock.PropertyMock(False))
44+
45+
46+
mock.patch.object(X(), "doesnotexist", mock.MagicMock())

tests/snapshots/mypy_test/test_check_files/patch_object_test/patch_object_test.out

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@ test_samples/patch_object_test.py:43: note: Possible overload variants:
3333
test_samples/patch_object_test.py:43: note: def object(target: Any, attribute: str, new: MagicMock[Any, Any] | bool, spec: Literal[False] | None = ..., create: bool = ..., spec_set: Literal[False] | None = ..., autospec: Literal[False] | None = ..., new_callable: None = ..., *, unsafe: bool = ...) -> _patch[MagicMock[Any, Any] | bool]
3434
test_samples/patch_object_test.py:43: note: def object(target: Any, attribute: str, *, spec: Any | Literal[False] | None = ..., create: bool = ..., spec_set: Any | Literal[False] | None = ..., autospec: Literal[False] | None = ..., new_callable: Callable[..., MagicMock[Any, Any] | bool], unsafe: bool = ..., **kwargs: Any) -> _patch_pass_arg[MagicMock[Any, Any] | bool]
3535
test_samples/patch_object_test.py:43: note: def object(target: Any, attribute: str, *, spec: Any | bool | None = ..., create: bool = ..., spec_set: Any | bool | None = ..., autospec: Any | bool | None = ..., new_callable: None = ..., unsafe: bool = ..., **kwargs: Any) -> _patch_pass_arg[MagicMock[[VarArg(Any), KwArg(Any)], Any] | AsyncMock[[VarArg(Any), KwArg(Any)], Any]]
36-
Found 11 errors in 1 file (checked 1 source file)
36+
test_samples/patch_object_test.py:46: error: "X" has no attribute "doesnotexist" [attr-defined]
37+
Found 12 errors in 1 file (checked 1 source file)

tests/snapshots/mypy_test/test_check_files/patch_test/patch_test.out

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ test_samples/patch_test.py:29: note: Possible overload variants:
1212
test_samples/patch_test.py:29: note: def __call__(self, target: str, new: Mock[[SupportsFloat | SupportsIndex], float] | Callable[[SupportsFloat | SupportsIndex], float], spec: Literal[False] | None = ..., create: bool = ..., spec_set: Literal[False] | None = ..., autospec: Literal[False] | None = ..., new_callable: None = ..., *, unsafe: bool = ...) -> _patch[Mock[[SupportsFloat | SupportsIndex], float] | Callable[[SupportsFloat | SupportsIndex], float]]
1313
test_samples/patch_test.py:29: note: def __call__(self, target: str, *, spec: Any | Literal[False] | None = ..., create: bool = ..., spec_set: Any | Literal[False] | None = ..., autospec: Literal[False] | None = ..., new_callable: Callable[..., Mock[[SupportsFloat | SupportsIndex], float] | Callable[[SupportsFloat | SupportsIndex], float]], unsafe: bool = ..., **kwargs: Any) -> _patch_pass_arg[Mock[[SupportsFloat | SupportsIndex], float] | Callable[[SupportsFloat | SupportsIndex], float]]
1414
test_samples/patch_test.py:29: note: def __call__(self, target: str, *, spec: Any | bool | None = ..., create: bool = ..., spec_set: Any | bool | None = ..., autospec: Any | bool | None = ..., new_callable: None = ..., unsafe: bool = ..., **kwargs: Any) -> _patch_pass_arg[MagicMock[[VarArg(Any), KwArg(Any)], Any] | AsyncMock[[VarArg(Any), KwArg(Any)], Any]]
15+
test_samples/patch_test.py:31: error: 'unknown.unknown' does not exist. [name-defined]
1516
test_samples/patch_test.py:57: error: Cannot infer type of lambda [misc]
1617
test_samples/patch_test.py:57: error: Argument 2 to "__call__" of "_patcher" has incompatible type "Callable[[], None]"; expected "Mock[[str], None] | Callable[[str], None]" [arg-type]
1718
test_samples/patch_test.py:59: error: Cannot infer type of lambda [misc]
@@ -36,4 +37,4 @@ test_samples/patch_test.py:90: note: def __call__(self, target: str, *, spec
3637
test_samples/patch_test.py:92: error: Argument 2 to "__call__" of "_patcher" has incompatible type "PropertyMock[None]"; expected "Mock[[PropertyClass], int] | Callable[[PropertyClass], int]" [arg-type]
3738
test_samples/patch_test.py:101: error: Cannot infer type of lambda [misc]
3839
test_samples/patch_test.py:101: error: Argument 3 to "object" of "_patcher" has incompatible type "Callable[[], None]"; expected "Mock[[int], int] | Callable[[int], int]" [arg-type]
39-
Found 18 errors in 1 file (checked 1 source file)
40+
Found 19 errors in 1 file (checked 1 source file)

0 commit comments

Comments
 (0)