Skip to content

Commit

Permalink
Support patch multiple (#5)
Browse files Browse the repository at this point in the history
* add documentation
* add support for patch.multiple
  • Loading branch information
jdkandersson authored Jan 14, 2023
1 parent cdd9c10 commit 1bfb375
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 44 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

## [v1.4.0] - 2023-01-14

### Added

- Lint checks that enforce the use of any one or more of the `spec`,
`spec_set`, `autospec`, or `new_callable` arguments when calling
`unittest.mock.patch.multiple`.

## [v1.3.0] - 2023-01-14

### Added
Expand Down Expand Up @@ -49,3 +57,4 @@
[v1.1.0]: https://github.com/jdkandersson/flake8-mock-spec/releases/v1.1.0
[v1.2.0]: https://github.com/jdkandersson/flake8-mock-spec/releases/v1.2.0
[v1.3.0]: https://github.com/jdkandersson/flake8-mock-spec/releases/v1.3.0
[v1.4.0]: https://github.com/jdkandersson/flake8-mock-spec/releases/v1.4.0
65 changes: 55 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ rules have been defined:
the `new`, `spec`, `spec_set`, `autospec` or `new_callable` arguments
* `TMS021`: checks that `unittest.mock.patch.object` is called with any one or
more of the `new`, `spec`, `spec_set`, `autospec` or `new_callable` arguments
* `TMS022`: checks that `unittest.mock.patch.multiple` is called with any one
or more of the `spec`, `spec_set`, `autospec` or `new_callable` arguments

### Fix TMS010

Expand Down Expand Up @@ -153,8 +155,8 @@ def test_foo():
mocked_foo = mock.MagicMock(spec_set=Foo)
```

For more information about `mock.MagicMock` and how to use it, please refer to the
official documentation:
For more information about `mock.MagicMock` and how to use it, please refer to
the official documentation:
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock

### Fix TMS012
Expand Down Expand Up @@ -190,8 +192,8 @@ def test_foo():
mocked_foo = mock.NonCallableMock(spec_set=Foo)
```

For more information about `mock.NonCallableMock` and how to use it, please refer to the
official documentation:
For more information about `mock.NonCallableMock` and how to use it, please
refer to the official documentation:
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.NonCallableMock

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

### Fix TMS020

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

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

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

### Fix TMS021

This linting rule is triggered when calling unittest.mock.patch.object without
including one or more of the following arguments: `new`, `spec`, `spec_set`,
`autospec`, or `new_callable`.
This linting rule is triggered when calling `unittest.mock.patch.object`
without including one or more of the following arguments: `new`, `spec`,
`spec_set`, `autospec`, or `new_callable`.

For example, this code will trigger the rule:

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

For more information about `mock.patch.object` and how to use it, please refer
to the official documentation:
https://docs.python.org/3/library/unittest.mock.html#patch
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch.object

### Fix TMS022

This linting rule is triggered when calling `unittest.mock.patch.multiple`
without including one or more of the following arguments: `spec`, `spec_set`,
`autospec`, or `new_callable`.

For example, this code will trigger the rule:

```Python
from unittest import mock

@mock.patch.multiple("Foo", FIRST_PATCH='bar', SECOND_PATCH='baz')
def test_foo():
pass

with mock.patch.object("Foo", FIRST_PATCH='bar', SECOND_PATCH='baz') as mocked_foo:
pass

foo_patcher = patch("Foo", FIRST_PATCH='bar', SECOND_PATCH='baz')
```

To fix this issue, include one or more of the aforementioned arguments when
calling `mock.patch.multiple`. For example:

```Python
from unittest import mock

from foo import Foo

@mock.patch.multiple("Foo", spec=Foo, FIRST_PATCH='bar', SECOND_PATCH='baz')
def test_foo():
pass

with mock.patch.object("Foo", spec_set=Foo, FIRST_PATCH='bar', SECOND_PATCH='baz') as mocked_foo:
pass

foo_patcher = patch("Foo", autospec=True, FIRST_PATCH='bar', SECOND_PATCH='baz')
```

For more information about `mock.patch.multiple` and how to use it, please
refer to the official documentation:
https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch.multiple
74 changes: 41 additions & 33 deletions flake8_mock_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,41 @@

# The attribute actually does exist, mypy reports that it doesn't
PATCH_FUNCTION: str = mock.patch.__name__ # type: ignore
PATCH_ARGS = frozenset(("new", "spec", "spec_set", "autospec", "new_callable"))
PATCH_MULTIPLE_ARGS = frozenset(("spec", "spec_set", "autospec", "new_callable"))
PATCH_ARGS = frozenset(("new", *PATCH_MULTIPLE_ARGS))
PATCH_MSG_BASE = (
f"%s unittest.mock.%s should be called with any of the {', '.join(PATCH_ARGS)} arguments, "
f"{MORE_INFO_BASE}#fix-%s"
)
PATCH_CODE = f"{ERROR_CODE_PREFIX}020"
PATCH_MSG = PATCH_MSG_BASE % (PATCH_CODE, PATCH_FUNCTION, PATCH_CODE.lower())
PATCH_OBJECT_CODE = f"{ERROR_CODE_PREFIX}021"
PATCH_OBJECT_FUNCTION = (PATCH_FUNCTION, "object")
PATCH_OBJECT_FUNCTION = (PATCH_FUNCTION, mock.patch.object.__name__.rsplit("_", maxsplit=1)[-1])
PATCH_OBJECT_MSG = PATCH_MSG_BASE % (
PATCH_OBJECT_CODE,
".".join(PATCH_OBJECT_FUNCTION),
PATCH_OBJECT_CODE.lower(),
)
PATCH_FUNCTIONS = frozenset((PATCH_FUNCTION, PATCH_OBJECT_FUNCTION))
PATCH_MULTIPLE_FUNCTION = (
PATCH_FUNCTION,
mock.patch.multiple.__name__.rsplit("_", maxsplit=1)[-1],
)
PATCH_MULTIPLE_CODE = f"{ERROR_CODE_PREFIX}022"
PATCH_MULTIPLE_MSG = PATCH_MSG_BASE % (
PATCH_MULTIPLE_CODE,
".".join(PATCH_MULTIPLE_FUNCTION),
PATCH_MULTIPLE_CODE.lower(),
)
PATCH_ARGS_LOOKUP = {
PATCH_FUNCTION: PATCH_ARGS,
PATCH_OBJECT_FUNCTION: PATCH_ARGS,
PATCH_MULTIPLE_FUNCTION: PATCH_MULTIPLE_ARGS,
}
PATCH_MSG_LOOKUP = {
PATCH_FUNCTION: PATCH_MSG,
PATCH_OBJECT_FUNCTION: PATCH_OBJECT_MSG,
PATCH_MULTIPLE_FUNCTION: PATCH_MULTIPLE_MSG,
}


class Problem(NamedTuple):
Expand All @@ -78,39 +98,22 @@ class Problem(NamedTuple):
msg: str


def _get_fully_qualified_name(node: ast.expr) -> tuple[str, ...] | None:
def _get_fully_qualified_name(node: ast.expr) -> tuple[str, ...]:
"""Retrieve the fully qualified name of a call func node.
Args:
node: The node to get the name of.
Returns:
Tuple containing all the elements of the fully qualified name of the node or None if
unexpected nodes are found.
Tuple containing all the elements of the fully qualified name of the node.
"""
if isinstance(node, ast.Name):
return (node.id,)
if isinstance(node, ast.Attribute):
fully_qualified_parent = _get_fully_qualified_name(node.value)
if fully_qualified_parent:
return (*fully_qualified_parent, node.attr)
return None


def _check_patch_keywords(node: ast.Call, msg: str) -> Problem | None:
"""Check if the given patch call has expected arguments.
Args:
node: The patch call node to check.
msg: The error message to return if the check fails.
Returns:
Problem: If the patch call does not have the expected arguments.
None: If the patch call has the expected arguments.
"""
if not any(keyword.arg in PATCH_ARGS for keyword in node.keywords):
return Problem(lineno=node.lineno, col_offset=node.col_offset, msg=msg)
return None
return ()


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

if (
name == PATCH_FUNCTION
or fully_qualified_name is not None
and fully_qualified_name[-2:] == PATCH_OBJECT_FUNCTION
):
problem = _check_patch_keywords(
node=node, msg=PATCH_MSG if name == PATCH_FUNCTION else PATCH_OBJECT_MSG
)
if problem:
self.problems.append(problem)
patch_msg_lookup_key = next(
(key for key in (name, fully_qualified_name[-2:]) if key in PATCH_MSG_LOOKUP),
None,
)
if patch_msg_lookup_key in PATCH_MSG_LOOKUP:
if not any(
keyword.arg in PATCH_ARGS_LOOKUP[patch_msg_lookup_key] for keyword in node.keywords
):
self.problems.append(
Problem(
lineno=node.lineno,
col_offset=node.col_offset,
msg=PATCH_MSG_LOOKUP[patch_msg_lookup_key],
)
)

# Ensure recursion continues
self.generic_visit(node)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "flake8-mock-spec"
version = "1.3.0"
version = "1.4.0"
description = "A linter that checks mocks are constructed with the spec argument"
authors = ["David Andersson <[email protected]>"]
license = "Apache 2.0"
Expand Down
9 changes: 9 additions & 0 deletions tests/test_flake8_mock_spec_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
MOCK_SPEC_MSG,
NON_CALLABLE_MOCK_SPEC_CODE,
PATCH_CODE,
PATCH_MULTIPLE_CODE,
PATCH_OBJECT_CODE,
)

Expand Down Expand Up @@ -127,6 +128,14 @@ def test_fail(tmp_path: Path):
""",
id=f"{PATCH_OBJECT_CODE} disabled",
),
pytest.param(
f"""
from unittest import mock
mock.patch.multiple() # noqa: {PATCH_MULTIPLE_CODE}
""",
id=f"{PATCH_MULTIPLE_CODE} disabled",
),
],
)
def test_pass(code: str, tmp_path: Path):
Expand Down
28 changes: 28 additions & 0 deletions tests/test_flake8_mock_spec_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
MOCK_SPEC_MSG,
NON_CALLABLE_MOCK_SPEC_MSG,
PATCH_MSG,
PATCH_MULTIPLE_MSG,
PATCH_OBJECT_MSG,
Plugin,
)
Expand Down Expand Up @@ -244,6 +245,24 @@ def function_1():
),
pytest.param(
"""
@patch.multiple()
def function_1():
pass
""",
(f"2:1 {PATCH_MULTIPLE_MSG}",),
id="decorator multiple no arg",
),
pytest.param(
"""
@patch.multiple(new=1)
def function_1():
pass
""",
(f"2:1 {PATCH_MULTIPLE_MSG}",),
id="decorator multiple new arg",
),
pytest.param(
"""
@mock.patch()
def function_1():
pass
Expand Down Expand Up @@ -323,6 +342,15 @@ def function_1():
),
pytest.param(
"""
@patch.multiple(spec=1)
def function_1():
pass
""",
(),
id="decorator multiple spec arg",
),
pytest.param(
"""
@mock.patch(new=1)
def function_1():
pass
Expand Down

0 comments on commit 1bfb375

Please sign in to comment.