Skip to content

Commit 1bfb375

Browse files
authored
Support patch multiple (#5)
* add documentation * add support for patch.multiple
1 parent cdd9c10 commit 1bfb375

File tree

6 files changed

+143
-44
lines changed

6 files changed

+143
-44
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## [Unreleased]
44

5+
## [v1.4.0] - 2023-01-14
6+
7+
### Added
8+
9+
- Lint checks that enforce the use of any one or more of the `spec`,
10+
`spec_set`, `autospec`, or `new_callable` arguments when calling
11+
`unittest.mock.patch.multiple`.
12+
513
## [v1.3.0] - 2023-01-14
614

715
### Added
@@ -49,3 +57,4 @@
4957
[v1.1.0]: https://github.com/jdkandersson/flake8-mock-spec/releases/v1.1.0
5058
[v1.2.0]: https://github.com/jdkandersson/flake8-mock-spec/releases/v1.2.0
5159
[v1.3.0]: https://github.com/jdkandersson/flake8-mock-spec/releases/v1.3.0
60+
[v1.4.0]: https://github.com/jdkandersson/flake8-mock-spec/releases/v1.4.0

README.md

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ rules have been defined:
8282
the `new`, `spec`, `spec_set`, `autospec` or `new_callable` arguments
8383
* `TMS021`: checks that `unittest.mock.patch.object` is called with any one or
8484
more of the `new`, `spec`, `spec_set`, `autospec` or `new_callable` arguments
85+
* `TMS022`: checks that `unittest.mock.patch.multiple` is called with any one
86+
or more of the `spec`, `spec_set`, `autospec` or `new_callable` arguments
8587

8688
### Fix TMS010
8789

@@ -153,8 +155,8 @@ def test_foo():
153155
mocked_foo = mock.MagicMock(spec_set=Foo)
154156
```
155157

156-
For more information about `mock.MagicMock` and how to use it, please refer to the
157-
official documentation:
158+
For more information about `mock.MagicMock` and how to use it, please refer to
159+
the official documentation:
158160
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock
159161

160162
### Fix TMS012
@@ -190,8 +192,8 @@ def test_foo():
190192
mocked_foo = mock.NonCallableMock(spec_set=Foo)
191193
```
192194

193-
For more information about `mock.NonCallableMock` and how to use it, please refer to the
194-
official documentation:
195+
For more information about `mock.NonCallableMock` and how to use it, please
196+
refer to the official documentation:
195197
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.NonCallableMock
196198

197199
### Fix TMS013
@@ -233,7 +235,7 @@ https://docs.python.org/3/library/unittest.mock.html#unittest.mock.AsyncMock
233235

234236
### Fix TMS020
235237

236-
This linting rule is triggered when calling unittest.mock.patch without
238+
This linting rule is triggered when calling `unittest.mock.patch` without
237239
including one or more of the following arguments: `new`, `spec`, `spec_set`,
238240
`autospec`, or `new_callable`.
239241

@@ -272,13 +274,13 @@ foo_patcher = patch("Foo", autospec=True)
272274

273275
For more information about `mock.patch` and how to use it, please refer to the
274276
official documentation:
275-
https://docs.python.org/3/library/unittest.mock.html#patch
277+
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch
276278

277279
### Fix TMS021
278280

279-
This linting rule is triggered when calling unittest.mock.patch.object without
280-
including one or more of the following arguments: `new`, `spec`, `spec_set`,
281-
`autospec`, or `new_callable`.
281+
This linting rule is triggered when calling `unittest.mock.patch.object`
282+
without including one or more of the following arguments: `new`, `spec`,
283+
`spec_set`, `autospec`, or `new_callable`.
282284

283285
For example, this code will trigger the rule:
284286

@@ -317,4 +319,47 @@ foo_patcher = patch(Foo, "bar", autospec=True)
317319

318320
For more information about `mock.patch.object` and how to use it, please refer
319321
to the official documentation:
320-
https://docs.python.org/3/library/unittest.mock.html#patch
322+
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch.object
323+
324+
### Fix TMS022
325+
326+
This linting rule is triggered when calling `unittest.mock.patch.multiple`
327+
without including one or more of the following arguments: `spec`, `spec_set`,
328+
`autospec`, or `new_callable`.
329+
330+
For example, this code will trigger the rule:
331+
332+
```Python
333+
from unittest import mock
334+
335+
@mock.patch.multiple("Foo", FIRST_PATCH='bar', SECOND_PATCH='baz')
336+
def test_foo():
337+
pass
338+
339+
with mock.patch.object("Foo", FIRST_PATCH='bar', SECOND_PATCH='baz') as mocked_foo:
340+
pass
341+
342+
foo_patcher = patch("Foo", FIRST_PATCH='bar', SECOND_PATCH='baz')
343+
```
344+
345+
To fix this issue, include one or more of the aforementioned arguments when
346+
calling `mock.patch.multiple`. For example:
347+
348+
```Python
349+
from unittest import mock
350+
351+
from foo import Foo
352+
353+
@mock.patch.multiple("Foo", spec=Foo, FIRST_PATCH='bar', SECOND_PATCH='baz')
354+
def test_foo():
355+
pass
356+
357+
with mock.patch.object("Foo", spec_set=Foo, FIRST_PATCH='bar', SECOND_PATCH='baz') as mocked_foo:
358+
pass
359+
360+
foo_patcher = patch("Foo", autospec=True, FIRST_PATCH='bar', SECOND_PATCH='baz')
361+
```
362+
363+
For more information about `mock.patch.multiple` and how to use it, please
364+
refer to the official documentation:
365+
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch.multiple

flake8_mock_spec.py

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,41 @@
4747

4848
# The attribute actually does exist, mypy reports that it doesn't
4949
PATCH_FUNCTION: str = mock.patch.__name__ # type: ignore
50-
PATCH_ARGS = frozenset(("new", "spec", "spec_set", "autospec", "new_callable"))
50+
PATCH_MULTIPLE_ARGS = frozenset(("spec", "spec_set", "autospec", "new_callable"))
51+
PATCH_ARGS = frozenset(("new", *PATCH_MULTIPLE_ARGS))
5152
PATCH_MSG_BASE = (
5253
f"%s unittest.mock.%s should be called with any of the {', '.join(PATCH_ARGS)} arguments, "
5354
f"{MORE_INFO_BASE}#fix-%s"
5455
)
5556
PATCH_CODE = f"{ERROR_CODE_PREFIX}020"
5657
PATCH_MSG = PATCH_MSG_BASE % (PATCH_CODE, PATCH_FUNCTION, PATCH_CODE.lower())
5758
PATCH_OBJECT_CODE = f"{ERROR_CODE_PREFIX}021"
58-
PATCH_OBJECT_FUNCTION = (PATCH_FUNCTION, "object")
59+
PATCH_OBJECT_FUNCTION = (PATCH_FUNCTION, mock.patch.object.__name__.rsplit("_", maxsplit=1)[-1])
5960
PATCH_OBJECT_MSG = PATCH_MSG_BASE % (
6061
PATCH_OBJECT_CODE,
6162
".".join(PATCH_OBJECT_FUNCTION),
6263
PATCH_OBJECT_CODE.lower(),
6364
)
64-
PATCH_FUNCTIONS = frozenset((PATCH_FUNCTION, PATCH_OBJECT_FUNCTION))
65+
PATCH_MULTIPLE_FUNCTION = (
66+
PATCH_FUNCTION,
67+
mock.patch.multiple.__name__.rsplit("_", maxsplit=1)[-1],
68+
)
69+
PATCH_MULTIPLE_CODE = f"{ERROR_CODE_PREFIX}022"
70+
PATCH_MULTIPLE_MSG = PATCH_MSG_BASE % (
71+
PATCH_MULTIPLE_CODE,
72+
".".join(PATCH_MULTIPLE_FUNCTION),
73+
PATCH_MULTIPLE_CODE.lower(),
74+
)
75+
PATCH_ARGS_LOOKUP = {
76+
PATCH_FUNCTION: PATCH_ARGS,
77+
PATCH_OBJECT_FUNCTION: PATCH_ARGS,
78+
PATCH_MULTIPLE_FUNCTION: PATCH_MULTIPLE_ARGS,
79+
}
80+
PATCH_MSG_LOOKUP = {
81+
PATCH_FUNCTION: PATCH_MSG,
82+
PATCH_OBJECT_FUNCTION: PATCH_OBJECT_MSG,
83+
PATCH_MULTIPLE_FUNCTION: PATCH_MULTIPLE_MSG,
84+
}
6585

6686

6787
class Problem(NamedTuple):
@@ -78,39 +98,22 @@ class Problem(NamedTuple):
7898
msg: str
7999

80100

81-
def _get_fully_qualified_name(node: ast.expr) -> tuple[str, ...] | None:
101+
def _get_fully_qualified_name(node: ast.expr) -> tuple[str, ...]:
82102
"""Retrieve the fully qualified name of a call func node.
83103
84104
Args:
85105
node: The node to get the name of.
86106
87107
Returns:
88-
Tuple containing all the elements of the fully qualified name of the node or None if
89-
unexpected nodes are found.
108+
Tuple containing all the elements of the fully qualified name of the node.
90109
"""
91110
if isinstance(node, ast.Name):
92111
return (node.id,)
93112
if isinstance(node, ast.Attribute):
94113
fully_qualified_parent = _get_fully_qualified_name(node.value)
95114
if fully_qualified_parent:
96115
return (*fully_qualified_parent, node.attr)
97-
return None
98-
99-
100-
def _check_patch_keywords(node: ast.Call, msg: str) -> Problem | None:
101-
"""Check if the given patch call has expected arguments.
102-
103-
Args:
104-
node: The patch call node to check.
105-
msg: The error message to return if the check fails.
106-
107-
Returns:
108-
Problem: If the patch call does not have the expected arguments.
109-
None: If the patch call has the expected arguments.
110-
"""
111-
if not any(keyword.arg in PATCH_ARGS for keyword in node.keywords):
112-
return Problem(lineno=node.lineno, col_offset=node.col_offset, msg=msg)
113-
return None
116+
return ()
114117

115118

116119
class Visitor(ast.NodeVisitor):
@@ -147,16 +150,21 @@ def visit_Call(self, node: ast.Call) -> None: # pylint: disable=invalid-name
147150
)
148151
)
149152

150-
if (
151-
name == PATCH_FUNCTION
152-
or fully_qualified_name is not None
153-
and fully_qualified_name[-2:] == PATCH_OBJECT_FUNCTION
154-
):
155-
problem = _check_patch_keywords(
156-
node=node, msg=PATCH_MSG if name == PATCH_FUNCTION else PATCH_OBJECT_MSG
157-
)
158-
if problem:
159-
self.problems.append(problem)
153+
patch_msg_lookup_key = next(
154+
(key for key in (name, fully_qualified_name[-2:]) if key in PATCH_MSG_LOOKUP),
155+
None,
156+
)
157+
if patch_msg_lookup_key in PATCH_MSG_LOOKUP:
158+
if not any(
159+
keyword.arg in PATCH_ARGS_LOOKUP[patch_msg_lookup_key] for keyword in node.keywords
160+
):
161+
self.problems.append(
162+
Problem(
163+
lineno=node.lineno,
164+
col_offset=node.col_offset,
165+
msg=PATCH_MSG_LOOKUP[patch_msg_lookup_key],
166+
)
167+
)
160168

161169
# Ensure recursion continues
162170
self.generic_visit(node)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "flake8-mock-spec"
3-
version = "1.3.0"
3+
version = "1.4.0"
44
description = "A linter that checks mocks are constructed with the spec argument"
55
authors = ["David Andersson <[email protected]>"]
66
license = "Apache 2.0"

tests/test_flake8_mock_spec_integration.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
MOCK_SPEC_MSG,
1616
NON_CALLABLE_MOCK_SPEC_CODE,
1717
PATCH_CODE,
18+
PATCH_MULTIPLE_CODE,
1819
PATCH_OBJECT_CODE,
1920
)
2021

@@ -127,6 +128,14 @@ def test_fail(tmp_path: Path):
127128
""",
128129
id=f"{PATCH_OBJECT_CODE} disabled",
129130
),
131+
pytest.param(
132+
f"""
133+
from unittest import mock
134+
135+
mock.patch.multiple() # noqa: {PATCH_MULTIPLE_CODE}
136+
""",
137+
id=f"{PATCH_MULTIPLE_CODE} disabled",
138+
),
130139
],
131140
)
132141
def test_pass(code: str, tmp_path: Path):

tests/test_flake8_mock_spec_unit.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
MOCK_SPEC_MSG,
1515
NON_CALLABLE_MOCK_SPEC_MSG,
1616
PATCH_MSG,
17+
PATCH_MULTIPLE_MSG,
1718
PATCH_OBJECT_MSG,
1819
Plugin,
1920
)
@@ -244,6 +245,24 @@ def function_1():
244245
),
245246
pytest.param(
246247
"""
248+
@patch.multiple()
249+
def function_1():
250+
pass
251+
""",
252+
(f"2:1 {PATCH_MULTIPLE_MSG}",),
253+
id="decorator multiple no arg",
254+
),
255+
pytest.param(
256+
"""
257+
@patch.multiple(new=1)
258+
def function_1():
259+
pass
260+
""",
261+
(f"2:1 {PATCH_MULTIPLE_MSG}",),
262+
id="decorator multiple new arg",
263+
),
264+
pytest.param(
265+
"""
247266
@mock.patch()
248267
def function_1():
249268
pass
@@ -323,6 +342,15 @@ def function_1():
323342
),
324343
pytest.param(
325344
"""
345+
@patch.multiple(spec=1)
346+
def function_1():
347+
pass
348+
""",
349+
(),
350+
id="decorator multiple spec arg",
351+
),
352+
pytest.param(
353+
"""
326354
@mock.patch(new=1)
327355
def function_1():
328356
pass

0 commit comments

Comments
 (0)