diff --git a/deps/tests/bump/main/output/requirements.txt b/deps/tests/bump/main/output/requirements.txt index d40fc67..40389d3 100644 --- a/deps/tests/bump/main/output/requirements.txt +++ b/deps/tests/bump/main/output/requirements.txt @@ -1 +1 @@ -click==8.3.1 +click==8.3.2 diff --git a/pytest/fixture_names.py b/pytest/fixture_names.py new file mode 100644 index 0000000..2734f0c --- /dev/null +++ b/pytest/fixture_names.py @@ -0,0 +1,83 @@ +import ast +import sys + + +class FixtureNameChecker(ast.NodeVisitor): + def __init__(self, filename, source_lines): + self.filename = filename + self.source_lines = source_lines + self.issues = [] + self.pytest_aliases: set[str] = set() # names where name.fixture is the decorator + self.fixture_aliases: set[str] = set() # names that *are* pytest.fixture + + def visit_Import(self, node): + for alias in node.names: + if alias.name == "pytest": + self.pytest_aliases.add(alias.asname or alias.name) + self.generic_visit(node) + + def visit_ImportFrom(self, node): + if node.module == "pytest": + for alias in node.names: + if alias.name == "fixture": + self.fixture_aliases.add(alias.asname or alias.name) + self.generic_visit(node) + + def _is_fixture_decorator(self, decorator): + # Unwrap call: @pytest.fixture() or @pytest.fixture(autouse=True) + if isinstance(decorator, ast.Call): + decorator = decorator.func + # @pytest.fixture or @pt.fixture + if ( + isinstance(decorator, ast.Attribute) + and decorator.attr == "fixture" + and isinstance(decorator.value, ast.Name) + and decorator.value.id in self.pytest_aliases + ): + return True + # @fixture or @fx + if isinstance(decorator, ast.Name) and decorator.id in self.fixture_aliases: + return True + return False + + def _check_function(self, node): + if node.name.startswith("_") and any( + self._is_fixture_decorator(d) for d in node.decorator_list + ): + # Find the name's column in the source line — more reliable than +4 + # and handles 'async def' correctly (col_offset points to 'async'). + line = self.source_lines[node.lineno - 1] + name_col = line.index(node.name, node.col_offset) + self.issues.append((node.lineno, name_col, node.name)) + + def visit_FunctionDef(self, node): + self._check_function(node) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node): + self._check_function(node) + self.generic_visit(node) + + +def main(filenames): + exit_status = 0 + for filename in filenames: + with open(filename, "rb") as f: + source = f.read() + try: + tree = ast.parse(source, filename=filename) + except SyntaxError: + continue + source_lines = source.decode("utf-8", errors="replace").splitlines() + checker = FixtureNameChecker(filename, source_lines) + checker.visit(tree) + for lineno, col, name in checker.issues: + print( + f"{filename}:{lineno}:{col}: no need to make things look private in tests: '{name}'" + ) + exit_status = 99 + sys.exit(exit_status) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/pytest/ick.toml b/pytest/ick.toml index 2954c22..dd6ce01 100644 --- a/pytest/ick.toml +++ b/pytest/ick.toml @@ -4,3 +4,9 @@ impl = "python" scope = "file" inputs = ["*.py"] deps = ["fixit", "libcst"] + +[[rule]] +name = "fixture_names" +impl = "python" +scope = "file" +inputs = ["tests/*.py"] diff --git a/pytest/tests/fixture_names/main/input/test_example.py b/pytest/tests/fixture_names/main/input/test_example.py new file mode 100644 index 0000000..57ccdc0 --- /dev/null +++ b/pytest/tests/fixture_names/main/input/test_example.py @@ -0,0 +1,57 @@ +import pytest +from pytest import fixture +import pytest as pt +from pytest import fixture as fx + + +# Bad: leading underscore with @pytest.fixture (attribute form) +@pytest.fixture +def _plain_fixture(): + pass + + +# Bad: called with no args +@pytest.fixture() +def _called_fixture(): + pass + + +# Bad: called with autouse=True +@pytest.fixture(autouse=True) +def _autouse_fixture(): + pass + + +# Bad: from-import form +@fixture +def _direct_fixture(): + pass + + +# Bad: aliased module import +@pt.fixture +def _aliased_module_fixture(): + pass + + +# Bad: aliased direct import +@fx +def _aliased_direct_fixture(): + pass + + +# Bad: async fixture +@pytest.fixture +async def _async_fixture(): + pass + + +# Good: no underscore +@pytest.fixture +def plain_fixture(): + pass + + +# Good: underscore but no fixture decorator +def _helper(): + pass diff --git a/pytest/tests/fixture_names/main/output/output.txt b/pytest/tests/fixture_names/main/output/output.txt new file mode 100644 index 0000000..237a71a --- /dev/null +++ b/pytest/tests/fixture_names/main/output/output.txt @@ -0,0 +1,7 @@ +test_example.py:9:4: no need to make things look private in tests: '_plain_fixture' +test_example.py:15:4: no need to make things look private in tests: '_called_fixture' +test_example.py:21:4: no need to make things look private in tests: '_autouse_fixture' +test_example.py:27:4: no need to make things look private in tests: '_direct_fixture' +test_example.py:33:4: no need to make things look private in tests: '_aliased_module_fixture' +test_example.py:39:4: no need to make things look private in tests: '_aliased_direct_fixture' +test_example.py:45:10: no need to make things look private in tests: '_async_fixture'