Skip to content

Commit b915532

Browse files
committed
Suppressing FP no-member
1 parent a0f1481 commit b915532

File tree

10 files changed

+242
-20
lines changed

10 files changed

+242
-20
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Changelog
22

33
## [Unreleased]
4+
### Added
5+
- Suppressing FP `no-member` from [using workaround of accessing cls in setup fixture](https://github.com/pytest-dev/pytest/issues/3778#issuecomment-411899446)
46

57
## [0.1.2] - 2020-05-22
68
### Fixed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ def test_something(imported_fixture): # <- Redefining name 'imported_fixture' f
6666
...
6767
```
6868

69+
### `no-member`
70+
71+
FP when class attributes are defined in setup fixtures
72+
73+
```python
74+
import pytest
75+
76+
class TestClass(object):
77+
@staticmethod
78+
@pytest.fixture(scope='class', autouse=True)
79+
def setup_class(request):
80+
cls = request.cls
81+
cls.defined_in_setup_class = True
82+
83+
def test_foo(self):
84+
assert self.defined_in_setup_class # <- Instance of 'TestClass' has no 'defined_in_setup_class' member
85+
```
86+
6987
## Changelog
7088

7189
See [CHANGELOG](CHANGELOG.md).

pylint_pytest.py

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
import os
22
import sys
33
import inspect
4+
from collections import defaultdict
45

56
import astroid
67
from pylint.checkers.variables import VariablesChecker
8+
from pylint.checkers.typecheck import TypeChecker
79
import pylint
810
import pytest
911

1012

1113
def _is_pytest_mark_usefixtures(decorator):
1214
# expecting @pytest.mark.usefixture(...)
1315
try:
14-
if isinstance(decorator, astroid.Call):
15-
if decorator.func.attrname == 'usefixtures' and \
16-
decorator.func.expr.attrname == 'mark' and \
17-
decorator.func.expr.expr.name == 'pytest':
18-
return True
16+
if isinstance(decorator, astroid.Call) and \
17+
decorator.func.attrname == 'usefixtures' and \
18+
decorator.func.expr.attrname == 'mark' and \
19+
decorator.func.expr.expr.name == 'pytest':
20+
return True
1921
except AttributeError:
2022
pass
2123
return False
@@ -42,6 +44,31 @@ def _is_pytest_fixture(decorator):
4244
return False
4345

4446

47+
def _is_class_autouse_fixture(function):
48+
try:
49+
for decorator in function.decorators.nodes:
50+
if isinstance(decorator, astroid.Call):
51+
func = decorator.func
52+
53+
if func and func.attrname in ('fixture', 'yield_fixture') \
54+
and func.expr.name == 'pytest':
55+
56+
is_class = is_autouse = False
57+
58+
for kwarg in decorator.keywords or []:
59+
if kwarg.arg == 'scope' and kwarg.value.value == 'class':
60+
is_class = True
61+
if kwarg.arg == 'autouse' and kwarg.value.value is True:
62+
is_autouse = True
63+
64+
if is_class and is_autouse:
65+
return True
66+
except AttributeError:
67+
pass
68+
69+
return False
70+
71+
4572
def _can_use_fixture(function):
4673
if isinstance(function, astroid.FunctionDef):
4774

@@ -85,21 +112,26 @@ def pytest_sessionfinish(self, session):
85112
self.fixtures = session._fixturemanager._arg2fixturedefs
86113

87114

88-
ORIGINAL = {}
115+
ORIGINAL = defaultdict(dict)
89116

90117

91118
def unregister():
92-
VariablesChecker.add_message = ORIGINAL['add_message']
93-
del ORIGINAL['add_message']
94-
VariablesChecker.visit_functiondef = ORIGINAL['visit_functiondef']
95-
del ORIGINAL['visit_functiondef']
96-
VariablesChecker.visit_module = ORIGINAL['visit_module']
97-
del ORIGINAL['visit_module']
119+
VariablesChecker.add_message = ORIGINAL['VariablesChecker']['add_message']
120+
VariablesChecker.visit_functiondef = ORIGINAL['VariablesChecker']['visit_functiondef']
121+
VariablesChecker.visit_module = ORIGINAL['VariablesChecker']['visit_module']
122+
del ORIGINAL['VariablesChecker']
123+
TypeChecker.in_setup = False
124+
TypeChecker.request_cls = set()
125+
TypeChecker.class_node = None
126+
TypeChecker.visit_assignattr = ORIGINAL['TypeChecker']['visit_assignattr']
127+
TypeChecker.visit_assign = ORIGINAL['TypeChecker']['visit_assign']
128+
TypeChecker.visit_functiondef = ORIGINAL['TypeChecker']['visit_functiondef']
129+
del ORIGINAL['TypeChecker']
98130

99131

100132
# pylint: disable=protected-access
101133
def register(_):
102-
'''Patch VariablesChecker to add additional checks for pytest fixtures
134+
'''Patch Pylint Checker classes to add additional checks for pytest
103135
'''
104136
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
105137
def patched_visit_module(self, node):
@@ -134,8 +166,8 @@ def patched_visit_module(self, node):
134166
# restore output devices
135167
sys.stdout, sys.stderr = stdout, stderr
136168

137-
ORIGINAL['visit_module'](self, node)
138-
ORIGINAL['visit_module'] = VariablesChecker.visit_module
169+
ORIGINAL['VariablesChecker']['visit_module'](self, node)
170+
ORIGINAL['VariablesChecker']['visit_module'] = VariablesChecker.visit_module
139171
VariablesChecker.visit_module = patched_visit_module
140172
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
141173

@@ -156,8 +188,8 @@ def patched_visit_functiondef(self, node):
156188
for arg in node.args.args:
157189
self._invoked_with_func_args.add(arg.name)
158190

159-
ORIGINAL['visit_functiondef'](self, node)
160-
ORIGINAL['visit_functiondef'] = VariablesChecker.visit_functiondef
191+
ORIGINAL['VariablesChecker']['visit_functiondef'](self, node)
192+
ORIGINAL['VariablesChecker']['visit_functiondef'] = VariablesChecker.visit_functiondef
161193
VariablesChecker.visit_functiondef = patched_visit_functiondef
162194
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
163195

@@ -209,12 +241,65 @@ def patched_add_message(self, msgid, line=None, node=None, args=None,
209241
return
210242

211243
if int(pylint.__version__.split('.')[0]) >= 2:
212-
ORIGINAL['add_message'](
244+
ORIGINAL['VariablesChecker']['add_message'](
213245
self, msgid, line, node, args, confidence, col_offset)
214246
else:
215247
# python2 + pylint1.9 backward compatibility
216-
ORIGINAL['add_message'](
248+
ORIGINAL['VariablesChecker']['add_message'](
217249
self, msgid, line, node, args, confidence)
218-
ORIGINAL['add_message'] = VariablesChecker.add_message
250+
ORIGINAL['VariablesChecker']['add_message'] = VariablesChecker.add_message
219251
VariablesChecker.add_message = patched_add_message
220252
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
253+
254+
# remembering current state across visit_* methods
255+
TypeChecker.in_setup = False
256+
TypeChecker.request_cls = set()
257+
TypeChecker.class_node = None
258+
259+
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
260+
def visit_assignattr(self, node):
261+
if TypeChecker.in_setup and isinstance(node.expr, astroid.Name) and \
262+
node.expr.name in TypeChecker.request_cls and \
263+
node.attrname not in TypeChecker.class_node.locals:
264+
try:
265+
# find Assign node which contains the source "value"
266+
assign_node = node
267+
while not isinstance(assign_node, astroid.Assign):
268+
assign_node = assign_node.parent
269+
270+
# hack class locals
271+
TypeChecker.class_node.locals[node.attrname] = [assign_node.value]
272+
except: # pylint: disable=bare-except
273+
# cannot find valid assign expr, skipping the entire attribute
274+
pass
275+
ORIGINAL['TypeChecker']['visit_assignattr'](self, node)
276+
ORIGINAL['TypeChecker']['visit_assignattr'] = TypeChecker.visit_assignattr
277+
TypeChecker.visit_assignattr = visit_assignattr
278+
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
279+
280+
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
281+
def visit_assign(self, node):
282+
'''hijack visit_assign to store the aliases for `cls`'''
283+
if TypeChecker.in_setup and isinstance(node.value, astroid.Attribute) and \
284+
node.value.attrname == 'cls' and node.value.expr.name == 'request':
285+
# storing the aliases for cls from request.cls
286+
TypeChecker.request_cls = set(map(lambda t: t.name, node.targets))
287+
ORIGINAL['TypeChecker']['visit_assign'](self, node)
288+
ORIGINAL['TypeChecker']['visit_assign'] = TypeChecker.visit_assign
289+
TypeChecker.visit_assign = visit_assign
290+
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
291+
292+
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
293+
def visit_functiondef(self, node):
294+
'''determine if a method is class setup method'''
295+
TypeChecker.in_setup = False
296+
TypeChecker.request_cls = set()
297+
TypeChecker.class_node = None
298+
299+
if _can_use_fixture(node) and _is_class_autouse_fixture(node):
300+
TypeChecker.in_setup = True
301+
TypeChecker.class_node = node.parent
302+
ORIGINAL['TypeChecker']['visit_functiondef'](self, node)
303+
ORIGINAL['TypeChecker']['visit_functiondef'] = TypeChecker.visit_functiondef
304+
TypeChecker.visit_functiondef = visit_functiondef
305+
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import pytest
2+
3+
4+
class TestClass(object):
5+
@staticmethod
6+
@pytest.fixture(scope='class', autouse=True)
7+
def setup_class(request):
8+
cls = request.cls
9+
cls.defined_in_setup_class = object()
10+
cls.defined_in_setup_class.attr_of_attr = None
11+
12+
def test_foo(self):
13+
assert self.defined_in_setup_class

tests/input/no-member/fixture.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pytest
2+
3+
4+
class TestClass(object):
5+
@staticmethod
6+
@pytest.fixture(scope='class', autouse=True)
7+
def setup_class(request):
8+
cls = request.cls
9+
cls.defined_in_setup_class = 123
10+
11+
def test_foo(self):
12+
assert self.defined_in_setup_class

tests/input/no-member/from_unpack.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import pytest
2+
3+
4+
def meh():
5+
return True, False
6+
7+
8+
class TestClass(object):
9+
@staticmethod
10+
@pytest.fixture(scope='class', autouse=True)
11+
def setup_class(request):
12+
cls = request.cls
13+
cls.defined_in_setup_class, _ = meh()
14+
15+
def test_foo(self):
16+
assert self.defined_in_setup_class

tests/input/no-member/inheritance.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import pytest
2+
3+
4+
class TestClass(object):
5+
@staticmethod
6+
@pytest.fixture(scope='class', autouse=True)
7+
def setup_class(request):
8+
cls = request.cls
9+
cls.defined_in_setup_class = 123
10+
11+
12+
class TestChildClass(TestClass):
13+
def test_foo(self):
14+
assert self.defined_in_setup_class
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pytest
2+
3+
4+
class TestClass(object):
5+
@staticmethod
6+
@pytest.fixture(scope='class', autouse=True)
7+
def setup_class(request):
8+
clls = request.cls
9+
clls.defined_in_setup_class = 123
10+
11+
def test_foo(self):
12+
assert self.defined_in_setup_class
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pytest
2+
3+
4+
class TestClass(object):
5+
@staticmethod
6+
@pytest.yield_fixture(scope='class', autouse=True)
7+
def setup_class(request):
8+
cls = request.cls
9+
cls.defined_in_setup_class = 123
10+
11+
def test_foo(self):
12+
assert self.defined_in_setup_class

tests/test_no_member.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pytest
2+
from pylint.checkers.typecheck import TypeChecker
3+
from base_tester import BasePytestChecker
4+
5+
6+
class TestNoMember(BasePytestChecker):
7+
CHECKER_CLASS = TypeChecker
8+
MSG_ID = 'no-member'
9+
10+
@pytest.mark.parametrize('enable_plugin', [True, False])
11+
def test_fixture(self, enable_plugin):
12+
self.run_linter(enable_plugin)
13+
self.verify_messages(0 if enable_plugin else 1)
14+
15+
@pytest.mark.parametrize('enable_plugin', [True, False])
16+
def test_yield_fixture(self, enable_plugin):
17+
self.run_linter(enable_plugin)
18+
self.verify_messages(0 if enable_plugin else 1)
19+
20+
@pytest.mark.parametrize('enable_plugin', [True, False])
21+
def test_not_using_cls(self, enable_plugin):
22+
self.run_linter(enable_plugin)
23+
self.verify_messages(0 if enable_plugin else 1)
24+
25+
@pytest.mark.parametrize('enable_plugin', [True, False])
26+
def test_inheritance(self, enable_plugin):
27+
self.run_linter(enable_plugin)
28+
self.verify_messages(0 if enable_plugin else 1)
29+
30+
@pytest.mark.parametrize('enable_plugin', [True, False])
31+
def test_from_unpack(self, enable_plugin):
32+
self.run_linter(enable_plugin)
33+
self.verify_messages(0 if enable_plugin else 1)
34+
35+
@pytest.mark.parametrize('enable_plugin', [True, False])
36+
def test_assign_attr_of_attr(self, enable_plugin):
37+
self.run_linter(enable_plugin)
38+
self.verify_messages(0 if enable_plugin else 1)

0 commit comments

Comments
 (0)