From 59dadc3b215de232f0f49f628db1f984ab4cba8b Mon Sep 17 00:00:00 2001 From: Tim Hatch Date: Mon, 13 Apr 2026 09:19:29 -0700 Subject: [PATCH 1/2] Fix test output for deps/bump --- deps/tests/bump/main/output/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 9456044c5b2e7e29751f0e0046d07586a3e61635 Mon Sep 17 00:00:00 2001 From: Tim Hatch Date: Mon, 13 Apr 2026 09:31:05 -0700 Subject: [PATCH 2/2] Add pytest/fixture_names rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flags underscore-prefixed pytest fixture functions — the leading underscore is unnecessary and misleading in test code since fixtures are never truly "private" or "unused". Co-Authored-By: Claude Sonnet 4.6 --- pytest/fixture_names.py | 83 +++++++++++++++++++ pytest/ick.toml | 6 ++ .../fixture_names/main/input/test_example.py | 57 +++++++++++++ .../fixture_names/main/output/output.txt | 7 ++ 4 files changed, 153 insertions(+) create mode 100644 pytest/fixture_names.py create mode 100644 pytest/tests/fixture_names/main/input/test_example.py create mode 100644 pytest/tests/fixture_names/main/output/output.txt 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'