DEVX-63: feat: extract generic tools into devx, expand devx.mak, remove personal references
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
"""Unit tests for devx.tools.check_mutable_globals."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_mutable_globals import (
|
||||
DEFAULT_SCAN_DIRS,
|
||||
DEFAULT_SKIP_DIRS,
|
||||
_load_config,
|
||||
_should_skip,
|
||||
cli,
|
||||
find_mutable_globals,
|
||||
)
|
||||
|
||||
|
||||
class TestFindMutableGlobals:
|
||||
def test_detects_set_global_with_path_hint(self, tmp_path: Path) -> None:
|
||||
source = "_SEEN: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "_SEEN" in issues[0]
|
||||
assert "set()" in issues[0]
|
||||
|
||||
def test_detects_dict_global_with_path_hint(self, tmp_path: Path) -> None:
|
||||
source = "_CACHE: dict[Path, Any] = {}\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "_CACHE" in issues[0]
|
||||
|
||||
def test_detects_list_global_with_path_hint(self, tmp_path: Path) -> None:
|
||||
source = "PATHS: list[Path] = []\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "PATHS" in issues[0]
|
||||
|
||||
def test_skips_non_mutable_globals(self, tmp_path: Path) -> None:
|
||||
source = "_MAX: int = 10\n_SEEN: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "_SEEN" in issues[0]
|
||||
|
||||
def test_skips_globals_without_path_hint(self, tmp_path: Path) -> None:
|
||||
source = "_DATA: dict[str, int] = {}\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_detects_path_type_annotation(self, tmp_path: Path) -> None:
|
||||
source = "_FILES: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_known_safe_exception(self, tmp_path: Path) -> None:
|
||||
source = "_SEEN: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
known_safe = {("mod.py", 1, "_SEEN")}
|
||||
issues = find_mutable_globals(f, tmp_path, known_safe)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text("def broken(:\n")
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert issues == []
|
||||
|
||||
def test_detects_mutable_literal_dict(self, tmp_path: Path) -> None:
|
||||
source = "_CACHE: dict[Path, Any] = {}\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_detects_mutable_literal_list(self, tmp_path: Path) -> None:
|
||||
source = "SEEN_PATHS: list[Path] = []\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_detects_mutable_literal_set(self, tmp_path: Path) -> None:
|
||||
source = "REGISTRY: set[Path] = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
|
||||
def test_skips_function_definitions(self, tmp_path: Path) -> None:
|
||||
source = "def foo():\n pass\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert issues == []
|
||||
|
||||
def test_handles_assign_with_name_target(self, tmp_path: Path) -> None:
|
||||
source = "SEEN_PATHS = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert len(issues) == 1
|
||||
assert "SEEN_PATHS" in issues[0]
|
||||
|
||||
def test_skips_annotation_without_value(self, tmp_path: Path) -> None:
|
||||
source = "_CACHE: dict[Path, Any]\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
assert issues == []
|
||||
|
||||
def test_skips_attribute_call(self, tmp_path: Path) -> None:
|
||||
# collections.defaultdict is an Attribute call, not a Name call
|
||||
source = "_CACHE: dict[Path, Any] = collections.defaultdict(list)\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
# Attribute calls are skipped (pass), so not flagged as mutable literal
|
||||
assert issues == []
|
||||
|
||||
def test_multiple_assign_targets(self, tmp_path: Path) -> None:
|
||||
source = "SEEN = CACHE = set()\n"
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text(source)
|
||||
issues = find_mutable_globals(f, tmp_path, set())
|
||||
# Both SEEN and CACHE should be flagged
|
||||
assert len(issues) == 2
|
||||
|
||||
|
||||
class TestShouldSkip:
|
||||
def test_skips_pycache(self) -> None:
|
||||
assert _should_skip(Path("/a/__pycache__/b.py"), DEFAULT_SKIP_DIRS) is True
|
||||
|
||||
def test_skips_venv(self) -> None:
|
||||
assert _should_skip(Path("/a/.venv/b.py"), DEFAULT_SKIP_DIRS) is True
|
||||
|
||||
def test_does_not_skip_normal(self) -> None:
|
||||
assert _should_skip(Path("/a/src/b.py"), DEFAULT_SKIP_DIRS) is False
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
def test_defaults_when_no_pyproject(self, tmp_path: Path) -> None:
|
||||
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value={}):
|
||||
scan_dirs, skip_dirs, known_safe = _load_config()
|
||||
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||
assert skip_dirs == DEFAULT_SKIP_DIRS
|
||||
assert known_safe == set()
|
||||
|
||||
def test_reads_config_from_pyproject(self) -> None:
|
||||
cfg = {
|
||||
"check_mutable_globals": {
|
||||
"scan_dirs": ["src", "tests"],
|
||||
"skip_dirs": ["__pycache__", ".tox"],
|
||||
"known_safe": ["src/mod.py:10:_CACHE"],
|
||||
}
|
||||
}
|
||||
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
|
||||
scan_dirs, skip_dirs, known_safe = _load_config()
|
||||
assert scan_dirs == ["src", "tests"]
|
||||
assert ".tox" in skip_dirs
|
||||
assert ("src/mod.py", 10, "_CACHE") in known_safe
|
||||
|
||||
def test_returns_defaults_when_cfg_not_dict(self) -> None:
|
||||
with patch(
|
||||
"devx.tools.check_mutable_globals._load_pyproject_devx",
|
||||
return_value={"check_mutable_globals": "not a dict"},
|
||||
):
|
||||
scan_dirs, skip_dirs, known_safe = _load_config()
|
||||
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||
assert skip_dirs == DEFAULT_SKIP_DIRS
|
||||
assert known_safe == set()
|
||||
|
||||
def test_known_safe_with_invalid_line_number(self) -> None:
|
||||
cfg = {"check_mutable_globals": {"known_safe": ["mod.py:abc:_CACHE"]}}
|
||||
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
|
||||
_, _, known_safe = _load_config()
|
||||
assert known_safe == set()
|
||||
|
||||
def test_scan_dirs_not_list_returns_default(self) -> None:
|
||||
cfg = {"check_mutable_globals": {"scan_dirs": "not a list"}}
|
||||
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
|
||||
scan_dirs, _, _ = _load_config()
|
||||
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_mutable_globals._load_config", return_value=(["empty_dir"], set(), set())),
|
||||
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Passed" in result.output
|
||||
|
||||
def test_fails_when_issues_found(self, tmp_path: Path) -> None:
|
||||
scan_dir = tmp_path / "src"
|
||||
scan_dir.mkdir()
|
||||
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], set(), set())),
|
||||
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "FAILED" in result.output
|
||||
|
||||
def test_scan_dir_option_overrides_config(self, tmp_path: Path) -> None:
|
||||
scan_dir = tmp_path / "custom"
|
||||
scan_dir.mkdir()
|
||||
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_mutable_globals._load_config", return_value=(["other"], set(), set())),
|
||||
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, ["--scan-dir", "custom"])
|
||||
assert result.exit_code != 0
|
||||
assert "FAILED" in result.output
|
||||
|
||||
def test_skips_files_in_skip_dirs(self, tmp_path: Path) -> None:
|
||||
scan_dir = tmp_path / "src"
|
||||
pycache = scan_dir / "__pycache__"
|
||||
pycache.mkdir(parents=True)
|
||||
(pycache / "mod.py").write_text("_SEEN: set[Path] = set()\n")
|
||||
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], {"__pycache__"}, set())),
|
||||
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Passed" in result.output
|
||||
Reference in New Issue
Block a user