Skip to content

Commit b65e979

Browse files
committed
Fix mypy issues
Most of the issues are "almost clear-cut", except the astroid stuff - `pylint_pytest/checkers/class_attr_loader.py`. Those were mostly placated (but hopefully done right). Additionally, add some sanity features to `BasePytestTester`: * Marked class as `ABC` * Enforce subclasses to initialize `MSG_ID` Mostly both of those changes are "aesthetical", and do not contribute to strict guarantees of behavior, or type assertions. But at least `pytest` cries loudly when used wrong - so that's something. Signed-off-by: Stavros Ntentos <133706+stdedos@users.noreply.github.com>
1 parent 33d1313 commit b65e979

File tree

7 files changed

+100
-37
lines changed

7 files changed

+100
-37
lines changed

pylint_pytest/checkers/class_attr_loader.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import astroid
1+
from __future__ import annotations
2+
3+
from astroid import Assign, Attribute, ClassDef, Name
24
from pylint.interfaces import IAstroidChecker
35

46
from ..utils import _can_use_fixture, _is_class_autouse_fixture
@@ -10,8 +12,8 @@ class ClassAttrLoader(BasePytestChecker):
1012
msgs = {"E6400": ("", "pytest-class-attr-loader", "")}
1113

1214
in_setup = False
13-
request_cls = set()
14-
class_node = None
15+
request_cls: set[str] = set()
16+
class_node: ClassDef | None = None
1517

1618
def visit_functiondef(self, node):
1719
"""determine if a method is a class setup method"""
@@ -23,12 +25,13 @@ def visit_functiondef(self, node):
2325
self.in_setup = True
2426
self.class_node = node.parent
2527

26-
def visit_assign(self, node):
28+
def visit_assign(self, node: Assign):
2729
"""store the aliases for `cls`"""
2830
if (
2931
self.in_setup
30-
and isinstance(node.value, astroid.Attribute)
32+
and isinstance(node.value, Attribute)
3133
and node.value.attrname == "cls"
34+
and isinstance(node.value.expr, Name)
3235
and node.value.expr.name == "request"
3336
):
3437
# storing the aliases for cls from request.cls
@@ -37,14 +40,15 @@ def visit_assign(self, node):
3740
def visit_assignattr(self, node):
3841
if (
3942
self.in_setup
40-
and isinstance(node.expr, astroid.Name)
43+
and isinstance(node.expr, Name)
4144
and node.expr.name in self.request_cls
45+
and self.class_node is not None
4246
and node.attrname not in self.class_node.locals
4347
):
4448
try:
4549
# find Assign node which contains the source "value"
4650
assign_node = node
47-
while not isinstance(assign_node, astroid.Assign):
51+
while not isinstance(assign_node, Assign):
4852
assign_node = assign_node.parent
4953

5054
# hack class locals

pylint_pytest/checkers/fixture.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import fnmatch
24
import os
35
import sys
@@ -17,17 +19,19 @@
1719
_is_same_module,
1820
)
1921
from . import BasePytestChecker
22+
from .types import FixtureDict, replacement_add_message
2023

2124
# TODO: support pytest python_files configuration
22-
FILE_NAME_PATTERNS = ("test_*.py", "*_test.py")
25+
FILE_NAME_PATTERNS: tuple[str, ...] = ("test_*.py", "*_test.py")
2326
ARGUMENT_ARE_KEYWORD_ONLY = (
2427
"https://docs.pytest.org/en/stable/deprecations.html#pytest-fixture-arguments-are-keyword-only"
2528
)
2629

2730

2831
class FixtureCollector:
29-
fixtures = {}
30-
errors = set()
32+
# Same as ``_pytest.fixtures.FixtureManager._arg2fixturedefs``.
33+
fixtures: FixtureDict = {}
34+
errors: set[pytest.CollectReport] = set()
3135

3236
def pytest_sessionfinish(self, session):
3337
# pylint: disable=protected-access
@@ -73,10 +77,13 @@ class FixtureChecker(BasePytestChecker):
7377
),
7478
}
7579

76-
_pytest_fixtures = {}
77-
_invoked_with_func_args = set()
78-
_invoked_with_usefixtures = set()
79-
_original_add_message = callable
80+
# Store all fixtures discovered by pytest session
81+
_pytest_fixtures: FixtureDict = {}
82+
# Stores all used function arguments
83+
_invoked_with_func_args: set[str] = set()
84+
# Stores all invoked fixtures through @pytest.mark.usefixture(...)
85+
_invoked_with_usefixtures: set[str] = set()
86+
_original_add_message = replacement_add_message
8087

8188
def open(self):
8289
# patch VariablesChecker.add_message
@@ -87,7 +94,7 @@ def close(self):
8794
"""restore & reset class attr for testing"""
8895
# restore add_message
8996
VariablesChecker.add_message = FixtureChecker._original_add_message
90-
FixtureChecker._original_add_message = callable
97+
FixtureChecker._original_add_message = replacement_add_message
9198

9299
# reset fixture info storage
93100
FixtureChecker._pytest_fixtures = {}
@@ -100,14 +107,9 @@ def visit_module(self, node):
100107
- invoke pytest session to collect available fixtures
101108
- create containers for the module to store args and fixtures
102109
"""
103-
# storing all fixtures discovered by pytest session
104-
FixtureChecker._pytest_fixtures = {} # Dict[List[_pytest.fixtures.FixtureDef]]
105-
106-
# storing all used function arguments
107-
FixtureChecker._invoked_with_func_args = set() # Set[str]
108-
109-
# storing all invoked fixtures through @pytest.mark.usefixture(...)
110-
FixtureChecker._invoked_with_usefixtures = set() # Set[str]
110+
FixtureChecker._pytest_fixtures = {}
111+
FixtureChecker._invoked_with_func_args = set()
112+
FixtureChecker._invoked_with_usefixtures = set()
111113

112114
is_test_module = False
113115
for pattern in FILE_NAME_PATTERNS:

pylint_pytest/checkers/types.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from pprint import pprint
5+
from typing import Any, Dict, List
6+
7+
from _pytest.fixtures import FixtureDef
8+
9+
FixtureDict = Dict[str, List[FixtureDef[Any]]]
10+
11+
12+
def replacement_add_message(*args, **kwargs):
13+
print("Called un-initialized _original_add_message with:", file=sys.stderr)
14+
pprint(args, sys.stderr)
15+
pprint(kwargs, sys.stderr)

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ ignore = [
8989
"RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
9090
]
9191

92+
# py36, but ruff does not support it :/
93+
target-version = "py37"
94+
9295
[tool.ruff.pydocstyle]
9396
convention = "google"
9497

@@ -110,6 +113,8 @@ convention = "google"
110113

111114
[tool.pylint]
112115

116+
py-version = "3.6"
117+
113118
ignore-paths="tests/input" # Ignore test inputs
114119

115120
load-plugins= [

tests/base_tester.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
from __future__ import annotations
2+
13
import os
24
import sys
5+
from abc import ABC
36
from pprint import pprint
7+
from typing import Any
48

59
import astroid
6-
from pylint.testutils import UnittestLinter
10+
from pylint.testutils import MessageTest, UnittestLinter
711

812
try:
913
from pylint.utils import ASTWalker
@@ -15,30 +19,36 @@
1519

1620
import pylint_pytest.checkers.fixture
1721

18-
# XXX: allow all file name
22+
# XXX: allow all file names
1923
pylint_pytest.checkers.fixture.FILE_NAME_PATTERNS = ("*",)
2024

2125

22-
class BasePytestTester:
26+
class BasePytestTester(ABC):
2327
CHECKER_CLASS = BaseChecker
24-
IMPACTED_CHECKER_CLASSES = []
25-
MSG_ID = None
26-
msgs = None
27-
CONFIG = {}
28+
IMPACTED_CHECKER_CLASSES: list[BaseChecker] = []
29+
MSG_ID: str
30+
msgs: list[MessageTest] = []
31+
CONFIG: dict[str, Any] = {}
32+
33+
def __init_subclass__(cls, **kwargs):
34+
super().__init_subclass__(**kwargs)
35+
if not hasattr(cls, "MSG_ID") or not isinstance(cls.MSG_ID, str) or not cls.MSG_ID:
36+
raise TypeError("Subclasses must define a non-empty MSG_ID of type str")
2837

2938
enable_plugin = True
3039

31-
def run_linter(self, enable_plugin, file_path=None):
40+
def run_linter(self, enable_plugin):
3241
self.enable_plugin = enable_plugin
3342

34-
# pylint: disable=protected-access
35-
if file_path is None:
36-
module = sys._getframe(1).f_code.co_name.replace("test_", "", 1)
37-
file_path = os.path.join(os.getcwd(), "tests", "input", self.MSG_ID, module + ".py")
43+
# pylint: disable-next=protected-access
44+
target_test_file = sys._getframe(1).f_code.co_name.replace("test_", "", 1)
45+
file_path = os.path.join(
46+
os.getcwd(), "tests", "input", self.MSG_ID, target_test_file + ".py"
47+
)
3848

3949
with open(file_path) as fin:
4050
content = fin.read()
41-
module = astroid.parse(content, module_name=module)
51+
module = astroid.parse(content, module_name=target_test_file)
4252
module.file = fin.name
4353

4454
self.walk(module) # run all checkers

tests/base_tester_test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import pytest
2+
from base_tester import BasePytestTester
3+
4+
# pylint: disable=unused-variable
5+
6+
7+
def test_init_subclass_valid_msg_id():
8+
some_string = "some_string"
9+
10+
class ValidSubclass(BasePytestTester):
11+
MSG_ID = some_string
12+
13+
assert ValidSubclass.MSG_ID == some_string
14+
15+
16+
def test_init_subclass_no_msg_id():
17+
with pytest.raises(TypeError):
18+
19+
class NoMsgIDSubclass(BasePytestTester):
20+
pass
21+
22+
23+
@pytest.mark.parametrize("msg_id", [123, None, ""], ids=lambda msg_id: f"{msg_id=}")
24+
def test_init_subclass_invalid_msg_id_type(msg_id):
25+
with pytest.raises(TypeError):
26+
27+
class Subclass(BasePytestTester):
28+
MSG_ID = msg_id

tests/test_pytest_yield_fixture.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
class TestDeprecatedPytestYieldFixture(BasePytestTester):
77
CHECKER_CLASS = FixtureChecker
8-
IMPACTED_CHECKER_CLASSES = []
98
MSG_ID = "deprecated-pytest-yield-fixture"
109

1110
def test_smoke(self):

0 commit comments

Comments
 (0)