Skip to content

Commit 3987ae1

Browse files
author
Anis Da Silva Campos
committed
fix fixture checker
the current implementation provokes recursion errors because of the VariablesChecker.add_message patch not working properly. This rework fix the issue by replacing the original variable checker by a subclass, without patch.
1 parent bb1a8de commit 3987ae1

File tree

7 files changed

+141
-139
lines changed

7 files changed

+141
-139
lines changed

pylint_pytest/__init__.py

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,21 @@
1-
import glob
2-
import importlib
3-
import inspect
4-
import os
1+
from pylint.checkers.variables import VariablesChecker
2+
from pylint.lint import PyLinter
53

6-
from .checkers import BasePytestChecker
4+
from .checkers.class_attr_loader import ClassAttrLoader
5+
from .checkers.fixture import FixtureChecker
6+
from .checkers.variables import CustomVariablesChecker
77

88

9-
def register(linter):
10-
"""auto discover pylint checker classes"""
11-
dirname = os.path.dirname(__file__)
12-
for module in glob.glob(os.path.join(dirname, "checkers", "*.py")):
13-
# trim file extension
14-
module = os.path.splitext(module)[0]
9+
def register(linter: PyLinter) -> None:
10+
"""Register the checker classes"""
11+
remove_original_variables_checker(linter)
12+
linter.register_checker(CustomVariablesChecker(linter))
13+
linter.register_checker(FixtureChecker(linter))
14+
linter.register_checker(ClassAttrLoader(linter))
1515

16-
# use relative path only
17-
module = module.replace(dirname, "", 1)
1816

19-
# translate file path into module import path
20-
module = module.replace(os.sep, ".")
21-
22-
checker = importlib.import_module(module, package=os.path.basename(dirname))
23-
for attr_name in dir(checker):
24-
attr_val = getattr(checker, attr_name)
25-
if (
26-
attr_val != BasePytestChecker
27-
and inspect.isclass(attr_val)
28-
and issubclass(attr_val, BasePytestChecker)
29-
):
30-
linter.register_checker(attr_val(linter))
17+
def remove_original_variables_checker(linter: PyLinter) -> None:
18+
"""We need to remove VariablesChecker before registering CustomVariablesChecker"""
19+
variable_checkers = linter._checkers[VariablesChecker.name] # pylint: disable=protected-access
20+
for checker in [x for x in variable_checkers if isinstance(x, VariablesChecker)]:
21+
variable_checkers.remove(checker)

pylint_pytest/checkers/fixture.py

Lines changed: 13 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,15 @@
77

88
import astroid
99
import pytest
10-
from pylint.checkers.variables import VariablesChecker
1110

1211
from ..utils import (
1312
_can_use_fixture,
1413
_is_pytest_fixture,
1514
_is_pytest_mark,
1615
_is_pytest_mark_usefixtures,
17-
_is_same_module,
1816
)
1917
from . import BasePytestChecker
20-
from .types import FixtureDict, replacement_add_message
18+
from .types import FixtureDict
2119

2220
# TODO: support pytest python_files configuration
2321
FILE_NAME_PATTERNS: tuple[str, ...] = ("test_*.py", "*_test.py")
@@ -75,38 +73,28 @@ class FixtureChecker(BasePytestChecker):
7573
}
7674

7775
# Store all fixtures discovered by pytest session
78-
_pytest_fixtures: FixtureDict = {}
76+
pytest_fixtures: FixtureDict = {}
7977
# Stores all used function arguments
80-
_invoked_with_func_args: set[str] = set()
78+
invoked_with_func_args: set[str] = set()
8179
# Stores all invoked fixtures through @pytest.mark.usefixture(...)
82-
_invoked_with_usefixtures: set[str] = set()
83-
_original_add_message = replacement_add_message
84-
85-
def open(self):
86-
# patch VariablesChecker.add_message
87-
FixtureChecker._original_add_message = VariablesChecker.add_message
88-
VariablesChecker.add_message = FixtureChecker.patch_add_message
80+
invoked_with_usefixtures: set[str] = set()
8981

9082
def close(self):
9183
"""restore & reset class attr for testing"""
92-
# restore add_message
93-
VariablesChecker.add_message = FixtureChecker._original_add_message
94-
FixtureChecker._original_add_message = replacement_add_message
95-
9684
# reset fixture info storage
97-
FixtureChecker._pytest_fixtures = {}
98-
FixtureChecker._invoked_with_func_args = set()
99-
FixtureChecker._invoked_with_usefixtures = set()
85+
FixtureChecker.pytest_fixtures = {}
86+
FixtureChecker.invoked_with_func_args = set()
87+
FixtureChecker.invoked_with_usefixtures = set()
10088

10189
def visit_module(self, node):
10290
"""
10391
- only run once per module
10492
- invoke pytest session to collect available fixtures
10593
- create containers for the module to store args and fixtures
10694
"""
107-
FixtureChecker._pytest_fixtures = {}
108-
FixtureChecker._invoked_with_func_args = set()
109-
FixtureChecker._invoked_with_usefixtures = set()
95+
FixtureChecker.pytest_fixtures = {}
96+
FixtureChecker.invoked_with_func_args = set()
97+
FixtureChecker.invoked_with_usefixtures = set()
11098

11199
is_test_module = False
112100
for pattern in FILE_NAME_PATTERNS:
@@ -140,7 +128,7 @@ def visit_module(self, node):
140128
# restore sys.path
141129
sys.path = sys_path
142130

143-
FixtureChecker._pytest_fixtures = fixture_collector.fixtures
131+
FixtureChecker.pytest_fixtures = fixture_collector.fixtures
144132

145133
legitimate_failure_paths = set(
146134
collection_report.nodeid
@@ -224,92 +212,11 @@ def visit_functiondef(self, node):
224212
if _is_pytest_mark_usefixtures(decorator):
225213
# save all visited fixtures
226214
for arg in decorator.args:
227-
self._invoked_with_usefixtures.add(arg.value)
215+
self.invoked_with_usefixtures.add(arg.value)
228216
if int(pytest.__version__.split(".")[0]) >= 3 and _is_pytest_fixture(
229217
decorator, fixture=False
230218
):
231219
# raise deprecated warning for @pytest.yield_fixture
232220
self.add_message("deprecated-pytest-yield-fixture", node=node)
233221
for arg in node.args.args:
234-
self._invoked_with_func_args.add(arg.name)
235-
236-
# pylint: disable=bad-staticmethod-argument # The function itself is an if-return logic.
237-
@staticmethod
238-
def patch_add_message(
239-
self, msgid, line=None, node=None, args=None, confidence=None, col_offset=None
240-
):
241-
"""
242-
- intercept and discard unwanted warning messages
243-
"""
244-
# check W0611 unused-import
245-
if msgid == "unused-import":
246-
# actual attribute name is not passed as arg so...dirty hack
247-
# message is usually in the form of '%s imported from %s (as %)'
248-
message_tokens = args.split()
249-
fixture_name = message_tokens[0]
250-
251-
# ignoring 'import %s' message
252-
if message_tokens[0] == "import" and len(message_tokens) == 2:
253-
pass
254-
255-
# fixture is defined in other modules and being imported to
256-
# conftest for pytest magic
257-
elif (
258-
isinstance(node.parent, astroid.Module)
259-
and node.parent.name.split(".")[-1] == "conftest"
260-
and fixture_name in FixtureChecker._pytest_fixtures
261-
):
262-
return
263-
264-
# imported fixture is referenced in test/fixture func
265-
elif (
266-
fixture_name in FixtureChecker._invoked_with_func_args
267-
and fixture_name in FixtureChecker._pytest_fixtures
268-
):
269-
if _is_same_module(
270-
fixtures=FixtureChecker._pytest_fixtures,
271-
import_node=node,
272-
fixture_name=fixture_name,
273-
):
274-
return
275-
276-
# fixture is referenced in @pytest.mark.usefixtures
277-
elif (
278-
fixture_name in FixtureChecker._invoked_with_usefixtures
279-
and fixture_name in FixtureChecker._pytest_fixtures
280-
):
281-
if _is_same_module(
282-
fixtures=FixtureChecker._pytest_fixtures,
283-
import_node=node,
284-
fixture_name=fixture_name,
285-
):
286-
return
287-
288-
# check W0613 unused-argument
289-
if (
290-
msgid == "unused-argument"
291-
and _can_use_fixture(node.parent.parent)
292-
and isinstance(node.parent, astroid.Arguments)
293-
):
294-
if node.name in FixtureChecker._pytest_fixtures:
295-
# argument is used as a fixture
296-
return
297-
298-
fixnames = (
299-
arg.name for arg in node.parent.args if arg.name in FixtureChecker._pytest_fixtures
300-
)
301-
for fixname in fixnames:
302-
if node.name in FixtureChecker._pytest_fixtures[fixname][0].argnames:
303-
# argument is used by a fixture
304-
return
305-
306-
# check W0621 redefined-outer-name
307-
if (
308-
msgid == "redefined-outer-name"
309-
and _can_use_fixture(node.parent.parent)
310-
and isinstance(node.parent, astroid.Arguments)
311-
and node.name in FixtureChecker._pytest_fixtures
312-
):
313-
return
314-
315-
FixtureChecker._original_add_message(self, msgid, line, node, args, confidence, col_offset)
222+
self.invoked_with_func_args.add(arg.name)

pylint_pytest/checkers/variables.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from typing import Any, Optional
2+
3+
from astroid import Arguments, Module
4+
from astroid.nodes.node_ng import NodeNG
5+
from pylint.checkers.variables import VariablesChecker
6+
from pylint.interfaces import Confidence
7+
8+
from pylint_pytest.utils import _can_use_fixture, _is_same_module
9+
10+
from .fixture import FixtureChecker
11+
12+
13+
class CustomVariablesChecker(VariablesChecker):
14+
"""Overrides the default VariablesChecker of pylint to discard unwanted warning messages"""
15+
16+
def add_message(
17+
self,
18+
msgid: str,
19+
line: Optional[int] = None,
20+
node: Optional[NodeNG] = None,
21+
args: Any = None,
22+
confidence: Confidence = None,
23+
col_offset: Optional[int] = None,
24+
end_lineno: Optional[int] = None,
25+
end_col_offset: Optional[int] = None,
26+
) -> None:
27+
"""
28+
- intercept and discard unwanted warning messages
29+
"""
30+
# check W0611 unused-import
31+
if msgid == "unused-import":
32+
# actual attribute name is not passed as arg so...dirty hack
33+
# message is usually in the form of '%s imported from %s (as %)'
34+
message_tokens = args.split()
35+
fixture_name = message_tokens[0]
36+
37+
# ignoring 'import %s' message
38+
if message_tokens[0] == "import" and len(message_tokens) == 2:
39+
pass
40+
41+
# fixture is defined in other modules and being imported to
42+
# conftest for pytest magic
43+
elif (
44+
node
45+
and isinstance(node.parent, Module)
46+
and node.parent.name.split(".")[-1] == "conftest"
47+
and fixture_name in FixtureChecker.pytest_fixtures
48+
):
49+
return
50+
51+
# imported fixture is referenced in test/fixture func
52+
elif (
53+
fixture_name in FixtureChecker.invoked_with_func_args
54+
and fixture_name in FixtureChecker.pytest_fixtures
55+
):
56+
if _is_same_module(
57+
fixtures=FixtureChecker.pytest_fixtures,
58+
import_node=node,
59+
fixture_name=fixture_name,
60+
):
61+
return
62+
63+
# fixture is referenced in @pytest.mark.usefixtures
64+
elif (
65+
fixture_name in FixtureChecker.invoked_with_usefixtures
66+
and fixture_name in FixtureChecker.pytest_fixtures
67+
):
68+
if _is_same_module(
69+
fixtures=FixtureChecker.pytest_fixtures,
70+
import_node=node,
71+
fixture_name=fixture_name,
72+
):
73+
return
74+
75+
# check W0613 unused-argument
76+
if (
77+
msgid == "unused-argument"
78+
and node
79+
and _can_use_fixture(node.parent.parent)
80+
and isinstance(node.parent, Arguments)
81+
):
82+
if node.name in FixtureChecker.pytest_fixtures:
83+
# argument is used as a fixture
84+
return
85+
86+
fixnames = (
87+
arg.name for arg in node.parent.args if arg.name in FixtureChecker.pytest_fixtures
88+
)
89+
for fixname in fixnames:
90+
if node.name in FixtureChecker.pytest_fixtures[fixname][0].argnames:
91+
# argument is used by a fixture
92+
return
93+
94+
# check W0621 redefined-outer-name
95+
if (
96+
msgid == "redefined-outer-name"
97+
and node
98+
and _can_use_fixture(node.parent.parent)
99+
and isinstance(node.parent, Arguments)
100+
and node.name in FixtureChecker.pytest_fixtures
101+
):
102+
return
103+
104+
super().add_message(msgid, line, node, args, confidence, col_offset)

tests/test_redefined_outer_name.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import pytest
22
from base_tester import BasePytestTester
3-
from pylint.checkers.variables import VariablesChecker
43

54
from pylint_pytest.checkers.fixture import FixtureChecker
5+
from pylint_pytest.checkers.variables import CustomVariablesChecker
66

77

88
class TestRedefinedOuterName(BasePytestTester):
99
CHECKER_CLASS = FixtureChecker
10-
IMPACTED_CHECKER_CLASSES = [VariablesChecker]
10+
IMPACTED_CHECKER_CLASSES = [CustomVariablesChecker]
1111
MSG_ID = "redefined-outer-name"
1212

1313
@pytest.mark.parametrize("enable_plugin", [True, False])

tests/test_regression.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import pytest
22
from base_tester import BasePytestTester
3-
from pylint.checkers.variables import VariablesChecker
43

54
from pylint_pytest.checkers.fixture import FixtureChecker
5+
from pylint_pytest.checkers.variables import CustomVariablesChecker
66

77

88
class TestRegression(BasePytestTester):
99
"""Covering some behaviors that shouldn't get impacted by the plugin"""
1010

1111
CHECKER_CLASS = FixtureChecker
12-
IMPACTED_CHECKER_CLASSES = [VariablesChecker]
12+
IMPACTED_CHECKER_CLASSES = [CustomVariablesChecker]
1313
MSG_ID = "regression"
1414

1515
@pytest.mark.parametrize("enable_plugin", [True, False])

tests/test_unused_argument.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import pytest
22
from base_tester import BasePytestTester
3-
from pylint.checkers.variables import VariablesChecker
43

54
from pylint_pytest.checkers.fixture import FixtureChecker
5+
from pylint_pytest.checkers.variables import CustomVariablesChecker
66

77

88
class TestUnusedArgument(BasePytestTester):
99
CHECKER_CLASS = FixtureChecker
10-
IMPACTED_CHECKER_CLASSES = [VariablesChecker]
10+
IMPACTED_CHECKER_CLASSES = [CustomVariablesChecker]
1111
MSG_ID = "unused-argument"
1212

1313
@pytest.mark.parametrize("enable_plugin", [True, False])

tests/test_unused_import.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import pytest
22
from base_tester import BasePytestTester
3-
from pylint.checkers.variables import VariablesChecker
43

54
from pylint_pytest.checkers.fixture import FixtureChecker
5+
from pylint_pytest.checkers.variables import CustomVariablesChecker
66

77

88
class TestUnusedImport(BasePytestTester):
99
CHECKER_CLASS = FixtureChecker
10-
IMPACTED_CHECKER_CLASSES = [VariablesChecker]
10+
IMPACTED_CHECKER_CLASSES = [CustomVariablesChecker]
1111
MSG_ID = "unused-import"
1212

1313
@pytest.mark.parametrize("enable_plugin", [True, False])

0 commit comments

Comments
 (0)