Skip to content

Commit 3b68fe7

Browse files
authored
feat: add mypy plugin (#6)
Closes #5
1 parent 6663b63 commit 3b68fe7

File tree

9 files changed

+228
-12
lines changed

9 files changed

+228
-12
lines changed

README.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,7 @@ pip install decoy
3636
poetry add --dev decoy
3737
```
3838

39-
## Usage
40-
41-
### Setup
39+
## Setup
4240

4341
You'll want to create a test fixture to reset Decoy state between each test run. In [pytest][], you can do this by using a fixture to create a new Decoy instance for every test.
4442

@@ -57,6 +55,25 @@ Why is this important? The `Decoy` container tracks every fake that is created d
5755

5856
[pytest]: https://docs.pytest.org/
5957

58+
### Mypy Setup
59+
60+
Decoy's rehearsal syntax can be a bit confusing to [mypy][] if the mock in question is supposed to return `None`. Normally, [mypy will complain][] if you try to use a `None`-returning expression as a value, because this is almost always a mistake.
61+
62+
In Decoy, however, it's an intentional part of the API and _not_ a mistake. To suppress these errors, Decoy provides a mypy plugin that you should add to your configuration file:
63+
64+
```ini
65+
# mypi.ini
66+
67+
# ...
68+
plugins = decoy.mypy
69+
# ...
70+
```
71+
72+
[mypy]: https://mypy.readthedocs.io/
73+
[mypy will complain]: https://mypy.readthedocs.io/en/stable/error_code_list.html#check-that-called-function-returns-a-value-func-returns-value
74+
75+
## Usage
76+
6077
### Stubbing
6178

6279
A stub is a an object used in a test that is pre-configured to return a result or raise an error if called according to a specification. In Decoy, you specify a stub's call expectations with a "rehearsal", which is simply a call to the stub inside of a `decoy.when` wrapper.
@@ -127,6 +144,16 @@ Stubbing and verification of a decoy are **mutually exclusive** within a test. I
127144
- The assertions are redundant
128145
- The dependency is doing too much based on its input (e.g. side-effecting _and_ calculating complex data) and should be refactored
129146

147+
### Usage with async/await
148+
149+
Decoy supports async/await out of the box! Pass your async function or class with async methods to `spec` in `decoy.create_decoy_func` or `decoy.create_decoy`, respectively, and Decoy will figure out the rest.
150+
151+
When writing rehearsals on async functions and methods, remember to include the `await` with your rehearsal call:
152+
153+
```py
154+
decoy.when(await mock_db.get_by_id("some-id")).then_return(mock_item)
155+
```
156+
130157
### Matchers
131158

132159
Sometimes, when you're stubbing or verifying calls (or really when you're doing any sort of equality assertion in a test), you need to loosen a given assertion. For example, you may want to assert that a dependency is called with a string, but you don't care about the full contents of that string.

decoy/mypy.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Decoy mypy plugin."""
2+
from typing import Callable, Optional, Type as ClassType
3+
4+
from mypy.errorcodes import FUNC_RETURNS_VALUE
5+
from mypy.plugin import Plugin, MethodContext
6+
from mypy.types import Type
7+
8+
9+
class DecoyPlugin(Plugin):
10+
"""A mypy plugin to remove otherwise valid errors from rehearsals."""
11+
12+
def get_method_hook(
13+
self, fullname: str
14+
) -> Optional[Callable[[MethodContext], Type]]:
15+
"""Remove any func-returns-value errors inside `when` or `verify` calls."""
16+
if fullname in {"decoy.Decoy.verify", "decoy.Decoy.when"}:
17+
return self._handle_decoy_call
18+
19+
return None
20+
21+
def _handle_decoy_call(self, ctx: MethodContext) -> Type:
22+
errors_list = ctx.api.msg.errors.error_info_map.get(ctx.api.path, [])
23+
rehearsal_call_args = ctx.args[0] if len(ctx.args) > 0 else []
24+
25+
for err in errors_list:
26+
for arg in rehearsal_call_args:
27+
if (
28+
err.code == FUNC_RETURNS_VALUE
29+
and arg.line == err.line
30+
and arg.column == err.column
31+
):
32+
errors_list.remove(err)
33+
34+
return ctx.default_return_type
35+
36+
37+
def plugin(version: str) -> ClassType[DecoyPlugin]:
38+
"""Get the DecoyPlugin class definition."""
39+
return DecoyPlugin

mkdocs.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
site_name: Decoy
22
site_description: "Opinionated, typed stubbing and verification library for Python."
3+
site_author: "Mike Cousins"
34
site_url: "https://mike.cousins.io/decoy/"
45
repo_url: "https://github.com/mcous/decoy"
56
repo_name: "mcous/decoy"
7+
edit_uri: "blob/main/docs/"
68

79
nav:
810
- User Guide: index.md
@@ -14,6 +16,8 @@ theme:
1416
name: "material"
1517
palette:
1618
scheme: preference
19+
features:
20+
- navigation.instant
1721

1822
plugins:
1923
- search

mypy.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
files = decoy,tests
33
strict = True
44
show_error_codes = True
5+
plugins = decoy.mypy

poetry.lock

Lines changed: 48 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ mkdocstrings = "^0.13.6"
3232
mypy = "^0.790"
3333
pytest = "^6.1.2"
3434
pytest-asyncio = "^0.14.0"
35+
pytest-mypy-plugins = "^1.6.1"
3536
pytest-xdist = "^2.1.0"
3637

3738
[tool.pytest.ini_options]
38-
addopts = "--color=yes"
39+
addopts = "--color=yes --mypy-ini-file=mypy.ini"
40+
3941
[build-system]
4042
requires = ["poetry>=0.12"]
4143
build-backend = "poetry.masonry.api"

tests/test_verify.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def test_call_no_return_method_then_verify(decoy: Decoy) -> None:
7373

7474
stub.do_the_thing(True)
7575

76-
decoy.verify(stub.do_the_thing(True)) # type: ignore[func-returns-value]
76+
decoy.verify(stub.do_the_thing(True))
7777

7878
with pytest.raises(AssertionError):
79-
decoy.verify(stub.do_the_thing(False)) # type: ignore[func-returns-value]
79+
decoy.verify(stub.do_the_thing(False))

tests/typing/test_mypy_plugin.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# mypy plugin tests
2+
3+
- case: does_not_suppress_func_returns_value
4+
main: |
5+
def noop() -> None:
6+
pass
7+
8+
value = noop()
9+
out: |
10+
main:4: error: "noop" does not return a value [func-returns-value]
11+
12+
- case: suppresses_func_returns_value_in_when
13+
main: |
14+
from decoy import Decoy
15+
16+
def noop() -> None:
17+
pass
18+
19+
decoy = Decoy()
20+
stub = decoy.when(noop())
21+
22+
- case: suppresses_func_returns_value_in_verify
23+
main: |
24+
from decoy import Decoy
25+
26+
def noop() -> None:
27+
pass
28+
29+
decoy = Decoy()
30+
decoy.verify(noop())
31+
32+
- case: does_not_suppress_other_errors
33+
main: |
34+
from decoy import Decoy
35+
36+
def do_thing() -> int:
37+
return 42
38+
39+
decoy = Decoy()
40+
stub = decoy.when(do_thing("hello"))
41+
out: |
42+
main:7: error: Too many arguments for "do_thing" [call-arg]

tests/typing/test_typing.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# typing tests
2+
3+
- case: class_decoy_mimics_type
4+
main: |
5+
from decoy import Decoy
6+
7+
class Dependency():
8+
def do_thing(self, input: str) -> int:
9+
return 42
10+
11+
decoy = Decoy()
12+
fake = decoy.create_decoy(spec=Dependency)
13+
reveal_type(fake)
14+
out: |
15+
main:9: note: Revealed type is 'main.Dependency*'
16+
17+
- case: function_decoy_mimics_type
18+
main: |
19+
from decoy import Decoy
20+
21+
def do_thing(input: str) -> int:
22+
return 42
23+
24+
decoy = Decoy()
25+
fake = decoy.create_decoy_func(spec=do_thing)
26+
reveal_type(fake)
27+
out: |
28+
main:8: note: Revealed type is 'def (input: builtins.str) -> builtins.int'
29+
30+
- case: class_stub_mimics_return_type
31+
main: |
32+
from decoy import Decoy
33+
34+
class Dependency():
35+
def do_thing(self, input: str) -> int:
36+
return 42
37+
38+
decoy = Decoy()
39+
fake = decoy.create_decoy(spec=Dependency)
40+
41+
decoy.when(fake.do_thing("hello")).then_return(42)
42+
decoy.when(fake.do_thing("goodbye")).then_return("wrong-type")
43+
out: |
44+
main:11: error: Argument 1 to "then_return" of "Stub" has incompatible type "str"; expected "int" [arg-type]
45+
46+
- case: function_stub_mimics_return_type
47+
main: |
48+
from decoy import Decoy
49+
50+
def do_thing(input: str) -> int:
51+
return 42
52+
53+
decoy = Decoy()
54+
fake = decoy.create_decoy_func(spec=do_thing)
55+
56+
decoy.when(fake("hello")).then_return(42)
57+
decoy.when(fake("goodbye")).then_return("wrong-type")
58+
out: |
59+
main:10: error: Argument 1 to "then_return" of "Stub" has incompatible type "str"; expected "int" [arg-type]

0 commit comments

Comments
 (0)