"""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