DEVX-136: feat: add fix_pr_title module and update_pr API method
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m0s

This commit was merged in pull request #203.
This commit is contained in:
2026-07-13 23:55:11 +00:00
parent 68f0872134
commit ddfbdec956
32 changed files with 2761 additions and 322 deletions
+804 -53
View File
@@ -2,14 +2,21 @@
from __future__ import annotations
import ast
import subprocess
import textwrap
from pathlib import Path
from unittest.mock import MagicMock
from click.testing import CliRunner
from devx.tools.check_test_isolation import (
HELPER_INTERNAL_CALLS,
KNOWN_SUBPROCESS_HELPERS,
CallGraph,
_extract_patch_targets,
_is_integration_test,
_SubprocessAudit,
analyze_file,
analyze_test_files,
cli,
@@ -502,6 +509,97 @@ class TestAnalyzeFile:
assert len(violations) == 1
assert violations[0].category == "syntax-error"
def test_heavy_module_import_at_module_level(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
import pandas
def test_foo() -> None:
assert True
""",
)
violations = analyze_file(file)
assert len(violations) == 1
assert violations[0].category == "heavy-module-import"
assert "pandas" in violations[0].message
def test_heavy_import_inside_function_ok(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
def test_foo() -> None:
import pandas
assert True
""",
)
violations = analyze_file(file)
assert violations == []
def test_heavy_import_from_at_module_level(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
from matplotlib import pyplot as plt
def test_foo() -> None:
assert True
""",
)
violations = analyze_file(file)
assert len(violations) == 1
assert violations[0].category == "heavy-module-import"
def test_reload_without_cleanup_odd_count(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
import importlib
import devx.config as cfg
def test_reload_no_cleanup() -> None:
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "CUSTOM"
""",
)
violations = analyze_file(file)
reload_violations = [v for v in violations if v.category == "reload-without-cleanup"]
assert len(reload_violations) == 1
assert "1 time(s)" in reload_violations[0].message
def test_reload_with_cleanup_even_count_ok(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
import importlib
import devx.config as cfg
def test_reload_with_cleanup() -> None:
importlib.reload(cfg)
assert cfg.TASK_PREFIX == "CUSTOM"
importlib.reload(cfg)
""",
)
violations = analyze_file(file)
reload_violations = [v for v in violations if v.category == "reload-without-cleanup"]
assert reload_violations == []
def test_reload_attribute_access_detected(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
import importlib
import devx.config
def test_reload_attr() -> None:
importlib.reload(devx.config)
""",
)
violations = analyze_file(file)
reload_violations = [v for v in violations if v.category == "reload-without-cleanup"]
assert len(reload_violations) == 1
assert "config" in reload_violations[0].message
class TestAnalyzeTestFiles:
def test_multiple_files(self, tmp_path: Path) -> None:
@@ -742,7 +840,8 @@ class TestCli:
assert "FAILED" in result.output
assert "unpatched-subprocess" in result.output
def test_strict_flag(self, tmp_path: Path) -> None:
def test_always_strict(self, tmp_path: Path) -> None:
"""CLI is always strict — no --strict flag needed."""
_write_test_file(
tmp_path,
"""
@@ -753,7 +852,7 @@ class TestCli:
""",
)
runner = CliRunner()
result = runner.invoke(cli, ["--test-path", str(tmp_path), "--strict"])
result = runner.invoke(cli, ["--test-path", str(tmp_path)])
assert result.exit_code == 1
def test_category_filter(self, tmp_path: Path) -> None:
@@ -795,22 +894,6 @@ class TestCli:
assert result.exit_code == 0
assert "no violations" in result.output
def test_strict_clean_directory_exits_zero(self, tmp_path: Path) -> None:
"""Strict mode with no violations should still exit 0."""
_write_test_file(
tmp_path,
"""
from unittest.mock import patch, MagicMock
class TestExample:
@patch("subprocess.run")
def test_ok(self, mock: MagicMock) -> None:
pass
""",
)
runner = CliRunner()
result = runner.invoke(cli, ["--test-path", str(tmp_path), "--strict"])
assert result.exit_code == 0
class TestPytestPlugin:
"""Tests for the pytest plugin hooks.
@@ -830,7 +913,7 @@ class TestPytestPlugin:
pytest_addoption(parser)
addoption_calls = parser.addoption.call_args_list
assert len(addoption_calls) >= 3
assert len(addoption_calls) >= 2
def test_pytest_collection_finish_noop_when_disabled(self) -> None:
"""Plugin should skip analysis when --no-test-isolation is set."""
@@ -854,39 +937,10 @@ class TestPytestPlugin:
pytest_collection_finish(session)
def test_pytest_collection_finish_with_violation(self, tmp_path: Path) -> None:
"""Plugin should emit warnings when violations are found."""
import warnings
"""Plugin should fail when hard violations are found (always strict)."""
from unittest.mock import MagicMock
from devx.tools.check_test_isolation import pytest_collection_finish
test_file = _write_test_file(
tmp_path,
"""
import subprocess
class TestExample:
def test_bad(self) -> None:
subprocess.run(["echo"])
""",
)
session = MagicMock()
session.config.getoption.side_effect = lambda opt: False
item = MagicMock()
item.fspath = str(test_file)
session.items = [item]
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
pytest_collection_finish(session)
assert len(w) >= 1
assert any("Test isolation violation" in str(warning.message) for warning in w)
def test_pytest_collection_finish_strict_mode(self, tmp_path: Path) -> None:
"""Plugin should emit warnings and print summary in strict mode."""
import warnings
from unittest.mock import MagicMock
import pytest
from devx.tools.check_test_isolation import pytest_collection_finish
@@ -903,7 +957,44 @@ class TestPytestPlugin:
session = MagicMock()
session.config.getoption.side_effect = lambda opt: {
"--no-test-isolation": False,
"--strict-test-isolation": True,
"--test-isolation-max-loop": 100,
}.get(opt, False)
item = MagicMock()
item.fspath = str(test_file)
session.items = [item]
with pytest.raises(pytest.fail.Exception, match="Test isolation"):
pytest_collection_finish(session)
def test_pytest_collection_finish_advisory_only(self, tmp_path: Path) -> None:
"""Transitive-subprocess advisories should warn, not fail."""
import warnings
from unittest.mock import MagicMock
from devx.tools.check_test_isolation import pytest_collection_finish
# Create a src/ directory with a module that calls subprocess.run
# so the call graph can detect transitive subprocess calls.
src_dir = tmp_path / "src" / "mypkg"
src_dir.mkdir(parents=True)
(src_dir / "__init__.py").write_text("")
(src_dir / "cli.py").write_text("import subprocess\ndef main():\n subprocess.run(['echo'])\n")
test_file = _write_test_file(
tmp_path,
"""
from click.testing import CliRunner
from mypkg.cli import main
class TestExample:
def test_advisory(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
""",
)
session = MagicMock()
session.config.getoption.side_effect = lambda opt: {
"--no-test-isolation": False,
"--test-isolation-max-loop": 100,
}.get(opt, False)
item = MagicMock()
@@ -914,5 +1005,665 @@ class TestPytestPlugin:
warnings.simplefilter("always")
pytest_collection_finish(session)
assert len(w) >= 1
assert any("Test isolation violation" in str(warning.message) for warning in w)
# Should only emit advisory warnings, not fail
assert any("advisory" in str(warning.message).lower() for warning in w)
class TestSubprocessAudit:
"""Tests for the _SubprocessAudit runtime wrapper (lines 157-195)."""
def test_ensure_installed_wraps_subprocess(self) -> None:
audit = _SubprocessAudit()
original_run = subprocess.run
try:
audit._ensure_installed()
assert audit._installed is True
assert "run" in audit._originals
# The subprocess.run should now be a wrapper, not the original
assert subprocess.run is not original_run
# Calling _ensure_installed again is a no-op (cached return)
audit._ensure_installed()
finally:
# Restore originals
for name, orig in audit._originals.items():
setattr(subprocess, name, orig)
def test_make_wrapper_records_calls_when_active(self) -> None:
audit = _SubprocessAudit()
mock_original = MagicMock(return_value="result")
wrapper = audit._make_wrapper("run", mock_original)
audit.start_test()
result = wrapper(["echo", "hi"], capture_output=True)
calls = audit.stop_test()
assert result == "result"
run_calls = [c for c in calls if c[0] == "run"]
assert len(run_calls) == 1
assert "echo" in run_calls[0][1]
mock_original.assert_called_once_with(["echo", "hi"], capture_output=True)
def test_make_wrapper_records_list_cmd_truncation(self) -> None:
"""Long command lists should be truncated to first 4 elements."""
audit = _SubprocessAudit()
mock_original = MagicMock(return_value="result")
wrapper = audit._make_wrapper("run", mock_original)
audit.start_test()
wrapper(["echo", "1", "2", "3", "4", "5", "6"], capture_output=True)
calls = audit.stop_test()
run_calls = [c for c in calls if c[0] == "run"]
assert len(run_calls) == 1
assert "..." in run_calls[0][1]
def test_make_wrapper_records_string_cmd(self) -> None:
"""A string command (not list) should be recorded as-is."""
audit = _SubprocessAudit()
mock_original = MagicMock(return_value="result")
wrapper = audit._make_wrapper("run", mock_original)
audit.start_test()
wrapper("echo hi", shell=True, capture_output=True)
calls = audit.stop_test()
run_calls = [c for c in calls if c[0] == "run"]
assert len(run_calls) == 1
assert "echo hi" in run_calls[0][1]
def test_calls_not_recorded_when_inactive(self) -> None:
"""When audit is not active, calls should not be recorded."""
audit = _SubprocessAudit()
mock_original = MagicMock(return_value="result")
wrapper = audit._make_wrapper("run", mock_original)
# Don't call start_test — audit inactive
wrapper(["echo", "hi"], capture_output=True)
# stop_test returns empty since no calls recorded
calls = audit.stop_test()
assert not calls
mock_original.assert_called_once_with(["echo", "hi"], capture_output=True)
def test_start_then_stop_returns_calls(self) -> None:
"""start_test initializes calls list, stop_test returns and clears it."""
audit = _SubprocessAudit()
mock_original = MagicMock(return_value="result")
wrapper = audit._make_wrapper("run", mock_original)
audit.start_test()
wrapper(["echo"], capture_output=True)
calls = audit.stop_test()
assert len(calls) == 1
# After stop, calls is cleared (None or empty)
calls2 = audit.stop_test()
assert not calls2
def test_ensure_installed_skips_missing_funcs(self) -> None:
"""If a subprocess func is missing (None), it should be skipped (line 162)."""
audit = _SubprocessAudit()
saved = subprocess.check_output
try:
# Temporarily make check_output "missing" (None)
subprocess.check_output = None # type: ignore[assignment]
audit._ensure_installed()
# check_output should NOT be in originals (skipped)
assert "check_output" not in audit._originals
# run should still be wrapped
assert "run" in audit._originals
finally:
subprocess.check_output = saved # type: ignore[assignment]
for name, orig in audit._originals.items():
setattr(subprocess, name, orig)
class TestExtractPatchTargets:
"""Tests for _extract_patch_targets (lines 263-291)."""
def _parse_func(self, source: str) -> ast.FunctionDef:
tree = ast.parse(textwrap.dedent(source))
return tree.body[0] # type: ignore[return-value]
def test_patch_object_extracted(self) -> None:
"""patch.object(module, "name") should extract the short name."""
node = self._parse_func(
"""
def test_foo():
with patch.object(mymodule, "subprocess"):
mymodule.do_thing()
"""
)
targets = _extract_patch_targets(node)
assert "subprocess" in targets
def test_patch_object_with_module_alias(self) -> None:
"""patch.object with a module alias Name as first arg."""
node = self._parse_func(
"""
def test_foo():
with patch.object(subprocess, "run"):
subprocess.run(["echo"])
"""
)
targets = _extract_patch_targets(node)
assert "run" in targets
def test_with_patch_context_manager_extracted(self) -> None:
"""with patch("module.func") in function body should be extracted."""
node = self._parse_func(
"""
def test_foo():
with patch("mymodule.subprocess.run"):
mymodule.do_thing()
"""
)
targets = _extract_patch_targets(node)
assert "mymodule.subprocess.run" in targets
assert "run" in targets
def test_with_multiple_patch_context_managers(self) -> None:
"""with patch("a"), patch("b") should extract both."""
node = self._parse_func(
"""
def test_foo():
with patch("mod.a"), patch("mod.b"):
pass
"""
)
targets = _extract_patch_targets(node)
assert "mod.a" in targets
assert "mod.b" in targets
assert "a" in targets
assert "b" in targets
def test_patch_object_non_string_second_arg_ignored(self) -> None:
"""patch.object with non-string 2nd arg should not crash."""
node = self._parse_func(
"""
def test_foo():
with patch.object(mymodule, some_var):
pass
"""
)
targets = _extract_patch_targets(node)
assert targets == set()
class TestCallGraph:
"""Tests for CallGraph building (lines 409, 419-420, 449, 470)."""
def _make_src(self, tmp_path: Path, files: dict[str, str]) -> Path:
src = tmp_path / "src"
src.mkdir()
for rel, content in files.items():
f = src / rel
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(textwrap.dedent(content))
return src
def test_ensure_built_cached(self, tmp_path: Path) -> None:
"""_ensure_built should only build once (cached return)."""
src = self._make_src(tmp_path, {"pkg/__init__.py": "", "pkg/mod.py": "def foo():\n pass\n"})
cg = CallGraph(src)
cg._ensure_built()
assert cg._built is True
nodes_before = dict(cg._nodes)
# Second call should be a no-op
cg._ensure_built()
assert cg._nodes == nodes_before
def test_build_skips_syntax_error(self, tmp_path: Path) -> None:
"""Files with syntax errors should be skipped, not crash."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/broken.py": "def test(:\n pass\n",
"pkg/good.py": "def foo():\n pass\n",
},
)
cg = CallGraph(src)
cg._ensure_built()
# good.py's foo should be registered, broken.py skipped
assert any("foo" in k for k in cg._nodes)
def test_build_skips_unicode_decode_error(self, tmp_path: Path) -> None:
"""Files with invalid UTF-8 should be skipped."""
src = tmp_path / "src"
src.mkdir()
(src / "pkg").mkdir()
(src / "pkg" / "__init__.py").write_text("")
(src / "pkg" / "binary.py").write_bytes(b"\xff\xfe\x00\xbad bytes")
(src / "pkg" / "good.py").write_text("def foo():\n pass\n")
cg = CallGraph(src)
cg._ensure_built()
assert any("foo" in k for k in cg._nodes)
def test_scan_node_skips_classdef(self, tmp_path: Path) -> None:
"""Methods inside classes should NOT be registered."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
class MyClass:
def my_method(self):
subprocess.run(["echo"])
def top_level():
pass
""",
},
)
cg = CallGraph(src)
cg._ensure_built()
# top_level should be registered
assert "pkg.mod.top_level" in cg._nodes
# my_method should NOT be registered (class body skipped)
assert "pkg.mod.my_method" not in cg._nodes
assert "my_method" not in cg._by_short
def test_register_function_records_io_calls(self, tmp_path: Path) -> None:
"""KNOWN_IO_FUNCTIONS calls should be recorded in io_calls."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
def foo():
get_pat("staging")
load_secrets("prod")
""",
},
)
cg = CallGraph(src)
cg._ensure_built()
node = cg._nodes["pkg.mod.foo"]
assert "get_pat" in node.io_calls
assert "load_secrets" in node.io_calls
def test_register_function_records_subprocess_calls(self, tmp_path: Path) -> None:
"""subprocess.run calls should be recorded in subprocess_calls."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
import subprocess
def foo():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
cg._ensure_built()
node = cg._nodes["pkg.mod.foo"]
assert "subprocess.run" in node.subprocess_calls
class TestFindReachableDangerous:
"""Tests for find_reachable_dangerous (lines 516-580)."""
def _make_src(self, tmp_path: Path, files: dict[str, str]) -> Path:
src = tmp_path / "src"
src.mkdir()
for rel, content in files.items():
f = src / rel
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(textwrap.dedent(content))
return src
def test_import_map_resolution(self, tmp_path: Path) -> None:
"""import_map should resolve target to a precise full name."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
import subprocess
def main():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
dangerous = cg.find_reachable_dangerous("main", set(), import_map={"main": "pkg.mod.main"})
assert len(dangerous) == 1
assert "subprocess" in dangerous[0][1]
def test_import_map_falls_back_to_short_name(self, tmp_path: Path) -> None:
"""If import_map value not in nodes, fall back to short name (line 516)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
import subprocess
def main():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
# import_map points to a non-existent full name → fallback to by_short
dangerous = cg.find_reachable_dangerous("main", set(), import_map={"main": "nonexistent.pkg.main"})
assert len(dangerous) == 1
def test_no_candidates_returns_empty(self, tmp_path: Path) -> None:
"""If no candidates found, return empty list (line 526)."""
src = self._make_src(tmp_path, {"pkg/__init__.py": ""})
cg = CallGraph(src)
dangerous = cg.find_reachable_dangerous("nonexistent", set())
assert dangerous == []
def test_fully_qualified_name_candidate(self, tmp_path: Path) -> None:
"""A fully-qualified target_name in nodes should be used directly (line 519)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
import subprocess
def main():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
dangerous = cg.find_reachable_dangerous("pkg.mod.main", set())
assert len(dangerous) == 1
def test_short_name_fallback(self, tmp_path: Path) -> None:
"""target_name not in nodes falls back to short name (line 522)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
import subprocess
def main():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
# "pkg.main" is not a full name in nodes, so it falls back to "main"
dangerous = cg.find_reachable_dangerous("pkg.main", set())
assert len(dangerous) == 1
def test_visited_prevents_infinite_loop(self, tmp_path: Path) -> None:
"""Visited set prevents infinite loops (line 535)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
def a():
b()
def b():
a()
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
dangerous = cg.find_reachable_dangerous("a", set())
assert len(dangerous) == 1
def test_depth_limit_stops_traversal(self, tmp_path: Path) -> None:
"""max_depth should stop traversal (line 534)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
def a():
b()
def b():
c()
def c():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
# With max_depth=0, only the direct node is visited
dangerous = cg.find_reachable_dangerous("a", set(), max_depth=0)
assert dangerous == []
def test_node_not_found_continues(self, tmp_path: Path) -> None:
"""If a queued node isn't in _nodes, continue (line 540)."""
src = self._make_src(tmp_path, {"pkg/__init__.py": ""})
cg = CallGraph(src)
# Manually inject a candidate that doesn't exist in nodes
cg._by_short["ghost"] = ["pkg.mod.ghost"]
dangerous = cg.find_reachable_dangerous("ghost", set())
assert dangerous == []
def test_io_calls_checked(self, tmp_path: Path) -> None:
"""IO calls should be reported as dangerous (lines 551-554)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
def main():
get_pat("staging")
""",
},
)
cg = CallGraph(src)
dangerous = cg.find_reachable_dangerous("main", set())
assert len(dangerous) == 1
assert "PAT" in dangerous[0][1] or "get_pat" in str(dangerous)
def test_io_calls_patched_skipped(self, tmp_path: Path) -> None:
"""Patched IO calls should not be reported."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
def main():
get_pat("staging")
""",
},
)
cg = CallGraph(src)
dangerous = cg.find_reachable_dangerous("main", {"get_pat"})
assert dangerous == []
def test_patched_helper_skipped_in_enqueue(self, tmp_path: Path) -> None:
"""A patched helper should not be enqueued (lines 559-560)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
def main():
run_cmd()
def run_cmd():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
# run_cmd is patched → should not traverse into it
dangerous = cg.find_reachable_dangerous("main", {"run_cmd"})
assert dangerous == []
def test_same_module_resolution(self, tmp_path: Path) -> None:
"""Calls within the same module should prefer same-module resolution (lines 566-567)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
def main():
helper()
def helper():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
dangerous = cg.find_reachable_dangerous("main", set())
assert len(dangerous) == 1
def test_short_name_single_match_resolution(self, tmp_path: Path) -> None:
"""A single global match by short name should be resolved (lines 571-572)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
def main():
helper()
""",
"pkg/other.py": """
import subprocess
def helper():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
dangerous = cg.find_reachable_dangerous("main", set())
assert len(dangerous) == 1
def test_is_patched_endswith(self, tmp_path: Path) -> None:
"""_is_patched should match patches ending with .short (line 580)."""
src = self._make_src(
tmp_path,
{
"pkg/__init__.py": "",
"pkg/mod.py": """
import subprocess
def main():
subprocess.run(["echo"])
""",
},
)
cg = CallGraph(src)
# "devx.ci.release.subprocess.run" ends with ".run"
dangerous = cg.find_reachable_dangerous("main", {"devx.ci.release.subprocess.run"})
assert dangerous == []
def test_is_patched_full_name_match(self) -> None:
"""_is_patched should match exact full name."""
assert CallGraph._is_patched("subprocess.run", "run", {"subprocess.run"}) is True
def test_is_patched_short_name_match(self) -> None:
"""_is_patched should match short name in patches."""
assert CallGraph._is_patched("subprocess.run", "run", {"run"}) is True
def test_is_patched_no_match(self) -> None:
"""_is_patched should return False when not patched."""
assert CallGraph._is_patched("subprocess.run", "run", {"other"}) is False
def test_is_patched_endswith_no_false_positive(self) -> None:
"""endswith should not match substrings (e.g. 'run' vs 'run_cmd')."""
assert CallGraph._is_patched("mod.run_cmd", "run_cmd", {"mod.run"}) is False
class TestVisitCallAttributeTarget:
"""Tests for visit_Call with ast.Attribute target (lines 851-862)."""
def test_invoke_with_module_func_attribute(self, tmp_path: Path) -> None:
"""runner.invoke(module.func) should resolve via import_map."""
src = tmp_path / "src"
src.mkdir()
(src / "pkg").mkdir()
(src / "pkg" / "__init__.py").write_text("")
(src / "pkg" / "cli.py").write_text("import subprocess\ndef main():\n subprocess.run(['echo'])\n")
test_file = tmp_path / "test_example.py"
test_file.write_text(
textwrap.dedent(
"""
from click.testing import CliRunner
import pkg.cli as cli_mod
class TestExample:
def test_invoke(self) -> None:
runner = CliRunner()
result = runner.invoke(cli_mod.main, [])
"""
)
)
cg = CallGraph(src)
violations = analyze_file(test_file, call_graph=cg)
transitive = [v for v in violations if v.category == "transitive-subprocess"]
assert len(transitive) == 1
def test_invoke_with_attribute_no_import_map(self, tmp_path: Path) -> None:
"""runner.invoke(mod.func) where mod not in import_map uses attr only (line 860)."""
src = tmp_path / "src"
src.mkdir()
(src / "pkg").mkdir()
(src / "pkg" / "__init__.py").write_text("")
(src / "pkg" / "cli.py").write_text("import subprocess\ndef main():\n subprocess.run(['echo'])\n")
test_file = tmp_path / "test_example.py"
test_file.write_text(
textwrap.dedent(
"""
from click.testing import CliRunner
class TestExample:
def test_invoke(self) -> None:
runner = CliRunner()
# unknown_mod not imported, so falls back to attr name
result = runner.invoke(unknown_mod.main, [])
"""
)
)
cg = CallGraph(src)
violations = analyze_file(test_file, call_graph=cg)
transitive = [v for v in violations if v.category == "transitive-subprocess"]
assert len(transitive) == 1
def test_invoke_with_attribute_non_name_value(self, tmp_path: Path) -> None:
"""runner.invoke(get_obj().func) — target.value is not a Name (line 862)."""
src = tmp_path / "src"
src.mkdir()
(src / "pkg").mkdir()
(src / "pkg" / "__init__.py").write_text("")
(src / "pkg" / "cli.py").write_text("import subprocess\ndef main():\n subprocess.run(['echo'])\n")
test_file = tmp_path / "test_example.py"
test_file.write_text(
textwrap.dedent(
"""
from click.testing import CliRunner
class TestExample:
def test_invoke(self) -> None:
runner = CliRunner()
result = runner.invoke(CliRunner().main, [])
"""
)
)
cg = CallGraph(src)
violations = analyze_file(test_file, call_graph=cg)
transitive = [v for v in violations if v.category == "transitive-subprocess"]
assert len(transitive) == 1
class TestIsIntegrationTest:
"""Tests for _is_integration_test (lines 1076-1080)."""
def test_marker_based_integration(self) -> None:
"""A test item with 'integration' in keywords should be detected."""
item = MagicMock()
item.keywords = {"integration", "test_foo"}
item.fspath = "tests/unit/test_foo.py"
assert _is_integration_test(item) is True
def test_path_based_integration(self) -> None:
"""A test item in an integration/ directory should be detected."""
item = MagicMock()
item.keywords = {"test_foo"}
item.fspath = "tests/integration/test_foo.py"
assert _is_integration_test(item) is True
def test_not_integration_test(self) -> None:
"""A regular test item should not be detected as integration."""
item = MagicMock()
item.keywords = {"test_foo"}
item.fspath = "tests/unit/test_foo.py"
assert _is_integration_test(item) is False
def test_no_keywords_attr(self) -> None:
"""An item without keywords attr should use fspath only."""
item = MagicMock()
item.keywords = {}
item.fspath = "tests/unit/test_foo.py"
assert _is_integration_test(item) is False