Skip to content
This repository was archived by the owner on Apr 29, 2024. It is now read-only.

Commit 0575104

Browse files
committed
init
0 parents  commit 0575104

22 files changed

+546
-0
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
*.pyc
2+
__pycache__/
3+
.tox/
4+
build/
5+
sandbox/**/*.py
6+
Pipfile*

.travis.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
language: python
2+
3+
matrix:
4+
include:
5+
- python: 2.7
6+
env: TOX_ENV=py27
7+
- python: 3.5
8+
env: TOX_ENV=py35
9+
- python: 3.6
10+
env: TOX_ENV=py36
11+
- python: 3.7
12+
env: TOX_ENV=py37
13+
- python: 3.8
14+
env: TOX_ENV=py38
15+
16+
install:
17+
- pip install tox
18+
19+
script:
20+
- tox -e $TOX_ENV
21+
22+
before_cache:
23+
- rm -rf $HOME/.cache/pip/log
24+
25+
cache:
26+
directories:
27+
- $HOME/.cache/pip
28+
29+
deploy:
30+
provider: pypi
31+
user: reverb
32+
on:
33+
tags: true
34+
condition: "$TOXENV = py37"
35+
distributions: bdist_wheel

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2020 Reverb Chu
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# pylint-pytest
2+
3+
A Pylint plugin to suppress false positive pylint warnings about pytest fixtures.
4+
5+
## Installation
6+
7+
To install:
8+
9+
```bash
10+
$ pip install pylint-pytest
11+
```
12+
13+
## Usage
14+
15+
```bash
16+
$ pylint --load-plugins pylint_pytest <path_to_your_sources>
17+
```
18+
19+
Or
20+
21+
## Suppressed Pylint Warnings
22+
23+
### `unused-argument`
24+
25+
26+
27+
### `unused-import`
28+
29+
30+
### `redefined-outer-name`
31+
32+
33+
## Tests
34+
35+
TBD
36+
37+
## License
38+
39+
`pylint-pytest` is available under [MIT license](LICENSE).

pylint_pytest.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import os
2+
import sys
3+
import inspect
4+
5+
import astroid
6+
from pylint.checkers.variables import VariablesChecker
7+
import pylint
8+
import pytest
9+
10+
11+
def _is_pytest_mark_usefixtures(decorator):
12+
# expecting @pytest.mark.usefixture(...)
13+
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
19+
except AttributeError:
20+
pass
21+
return False
22+
23+
24+
def _is_pytest_fixture(decorator):
25+
attr = None
26+
27+
try:
28+
if isinstance(decorator, astroid.Attribute):
29+
# expecting @pytest.fixture
30+
attr = decorator
31+
32+
if isinstance(decorator, astroid.Call):
33+
# expecting @pytest.fixture(scope=...)
34+
attr = decorator.func
35+
36+
if attr and attr.attrname == 'fixture' and attr.expr.name == 'pytest':
37+
return True
38+
except AttributeError:
39+
pass
40+
41+
return False
42+
43+
44+
def _can_use_fixture(function):
45+
if isinstance(function, astroid.FunctionDef):
46+
47+
# test_*, *_test
48+
if function.name.startswith('test_') or function.name.endswith('_test'):
49+
return True
50+
51+
if function.decorators:
52+
for decorator in function.decorators.nodes:
53+
# usefixture
54+
if _is_pytest_mark_usefixtures(decorator):
55+
return True
56+
57+
# fixture
58+
if _is_pytest_fixture(decorator):
59+
return True
60+
61+
return False
62+
63+
64+
def _is_same_module(fixtures, import_node, fixture_name):
65+
'''Comparing pytest fixture node with astroid.ImportFrom'''
66+
for fixture in fixtures[fixture_name]:
67+
for import_from in import_node.root().globals[fixture_name]:
68+
if inspect.getmodule(fixture.func).__file__ == \
69+
import_from.parent.import_module(import_from.modname).file:
70+
return True
71+
72+
return False
73+
74+
75+
# pylint: disable=protected-access
76+
class FixtureCollector:
77+
fixtures = {}
78+
79+
def pytest_sessionfinish(self, session):
80+
self.fixtures = session._fixturemanager._arg2fixturedefs
81+
82+
83+
ORIGINAL = {}
84+
85+
86+
def unregister():
87+
VariablesChecker.add_message = ORIGINAL['add_message']
88+
del ORIGINAL['add_message']
89+
VariablesChecker.visit_functiondef = ORIGINAL['visit_functiondef']
90+
del ORIGINAL['visit_functiondef']
91+
VariablesChecker.visit_module = ORIGINAL['visit_module']
92+
del ORIGINAL['visit_module']
93+
94+
95+
# pylint: disable=protected-access
96+
def register(_):
97+
'''Patch VariablesChecker to add additional checks for pytest fixtures
98+
'''
99+
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
100+
def patched_visit_module(self, node):
101+
'''
102+
- only run once per module
103+
- invoke pytest session to collect available fixtures
104+
- create containers for the module to store args and fixtures
105+
'''
106+
# storing all fixtures discovered by pytest session
107+
self._pytest_fixtures = {} # Dict[List[_pytest.fixtures.FixtureDef]]
108+
109+
# storing all used function arguments
110+
self._invoked_with_func_args = set() # Set[str]
111+
112+
# storing all invoked fixtures through @pytest.mark.usefixture(...)
113+
self._invoked_with_usefixtures = set() # Set[str]
114+
115+
try:
116+
with open(os.devnull, 'w') as devnull:
117+
# suppress any future output from pytest
118+
stdout, stderr = sys.stdout, sys.stderr
119+
sys.stderr = sys.stdout = devnull
120+
121+
# run pytest session with customized plugin to collect fixtures
122+
fixture_collector = FixtureCollector()
123+
pytest.main(
124+
[node.file, '--fixtures'],
125+
plugins=[fixture_collector],
126+
)
127+
self._pytest_fixtures = fixture_collector.fixtures
128+
finally:
129+
# restore output devices
130+
sys.stdout, sys.stderr = stdout, stderr
131+
132+
ORIGINAL['visit_module'](self, node)
133+
ORIGINAL['visit_module'] = VariablesChecker.visit_module
134+
VariablesChecker.visit_module = patched_visit_module
135+
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
136+
137+
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
138+
def patched_visit_functiondef(self, node):
139+
'''
140+
- save invoked fixtures for later use
141+
- save used function arguments for later use
142+
'''
143+
if _can_use_fixture(node):
144+
if node.decorators:
145+
# check all decorators
146+
for decorator in node.decorators.nodes:
147+
if _is_pytest_mark_usefixtures(decorator):
148+
# save all visited fixtures
149+
for arg in decorator.args:
150+
self._invoked_with_usefixtures.add(arg.value)
151+
for arg in node.args.args:
152+
self._invoked_with_func_args.add(arg.name)
153+
154+
ORIGINAL['visit_functiondef'](self, node)
155+
ORIGINAL['visit_functiondef'] = VariablesChecker.visit_functiondef
156+
VariablesChecker.visit_functiondef = patched_visit_functiondef
157+
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
158+
159+
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
160+
def patched_add_message(self, msgid, line=None, node=None, args=None,
161+
confidence=None, col_offset=None):
162+
'''
163+
- intercept and discard unwanted warning messages
164+
'''
165+
# check W0611 unused-import
166+
if msgid == 'unused-import':
167+
# actual attribute name is not passed as arg so...dirty hack
168+
# message is usually in the form of '%s imported from %s (as %)'
169+
message_tokens = args.split()
170+
fixture_name = message_tokens[0]
171+
172+
# ignoring 'import %s' message
173+
if message_tokens[0] == 'import' and len(message_tokens) == 2:
174+
pass
175+
176+
# imported fixture is referenced in test/fixture func
177+
elif fixture_name in self._invoked_with_func_args \
178+
and fixture_name in self._pytest_fixtures:
179+
if _is_same_module(fixtures=self._pytest_fixtures,
180+
import_node=node,
181+
fixture_name=fixture_name):
182+
return
183+
184+
# fixture is referenced in @pytest.mark.usefixtures
185+
elif fixture_name in self._invoked_with_usefixtures \
186+
and fixture_name in self._pytest_fixtures:
187+
if _is_same_module(fixtures=self._pytest_fixtures,
188+
import_node=node,
189+
fixture_name=fixture_name):
190+
return
191+
192+
# check W0613 unused-argument
193+
if msgid == 'unused-argument' and \
194+
node.name in self._pytest_fixtures and \
195+
_can_use_fixture(node.parent.parent):
196+
return
197+
198+
# check W0621 redefined-outer-name
199+
if msgid == 'redefined-outer-name' and \
200+
node.name in self._pytest_fixtures and \
201+
_can_use_fixture(node.parent.parent):
202+
return
203+
204+
if int(pylint.__version__.split('.')[0]) >= 2:
205+
ORIGINAL['add_message'](
206+
self, msgid, line, node, args, confidence, col_offset)
207+
else:
208+
# python2 + pylint1.9 backward compatibility
209+
ORIGINAL['add_message'](
210+
self, msgid, line, node, args, confidence)
211+
ORIGINAL['add_message'] = VariablesChecker.add_message
212+
VariablesChecker.add_message = patched_add_message
213+
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<

sandbox/.placeholder

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DON'T REMOVE ME

setup.cfg

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[aliases]
2+
test = pytest
3+
4+
[tool:pytest]
5+
addopts = --verbose
6+
python_files = tests/test_*.py

setup.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
4+
from setuptools import setup
5+
6+
7+
setup(
8+
name='pylint-pytest',
9+
version='0.1',
10+
author='Reverb Chu',
11+
author_email='[email protected]',
12+
maintainer='Reverb Chu',
13+
maintainer_email='[email protected]',
14+
license='MIT',
15+
url='https://github.com/reverbc/pylint-pytest',
16+
description='A Pylint plugin to suppress pytest fixture related false positive warnings.',
17+
py_modules=['pylint_pytest'],
18+
install_requires=[
19+
'pylint',
20+
'pytest>=4.6',
21+
],
22+
classifiers=[
23+
'Development Status :: 1 - Planning',
24+
'Intended Audience :: Developers',
25+
'Topic :: Software Development :: Testing',
26+
'Topic :: Software Development :: Quality Assurance',
27+
'Programming Language :: Python',
28+
'Programming Language :: Python :: 2',
29+
'Programming Language :: Python :: 2.7',
30+
'Programming Language :: Python :: 3',
31+
'Programming Language :: Python :: 3.5',
32+
'Programming Language :: Python :: 3.6',
33+
'Programming Language :: Python :: 3.7',
34+
'Programming Language :: Python :: 3.8',
35+
'Programming Language :: Python :: Implementation :: CPython',
36+
'Operating System :: OS Independent',
37+
'License :: OSI Approved :: MIT License',
38+
],
39+
tests_require=['pytest', 'pylint'],
40+
keywords=['pylint', 'pytest', 'plugin'],
41+
)

0 commit comments

Comments
 (0)