"""The quality ratchet: measured from the AST, held against a baseline (STANDARDS.md §6). Ported from netOrk's scripts/quality_ratchet.py (#402), Python only, plus the two settings a driver ratchets in pyproject.toml: modules exempt from strict mypy, and the coverage floor. """ from __future__ import annotations import ast import json import pathlib import textwrap import pytest from napalm_standard import ratchet as qr def _functions(source: str) -> dict: return qr.python_functions_in(textwrap.dedent(source), "mod.py") class TestMeasuring: def test_function_length_and_qualified_names(self) -> None: funcs = _functions( """ def top(): return 1 class C: def method(self): def inner(): return 2 return inner() """ ) assert funcs["mod.py::top"]["lines"] == 2 assert funcs["mod.py::C.method"]["lines"] == 4 assert funcs["mod.py::C.method.inner"]["lines"] == 2 def test_complexity_counts_decision_points(self) -> None: funcs = _functions( """ def f(a, b, xs): if a and b: pass elif a: pass for x in xs: while x: x -= 1 try: pass except ValueError: pass return [y for y in xs if y] if a else None """ ) # 1 + if + and + elif + for + while + except + comprehension + its if + ternary assert funcs["mod.py::f"]["complexity"] == 10 def test_match_cases_count(self) -> None: funcs = _functions( """ def f(x): match x: case 1: return 1 case _: return 0 """ ) assert funcs["mod.py::f"]["complexity"] == 3 def test_a_nested_function_counts_for_itself_only(self) -> None: funcs = _functions( """ def outer(): def inner(x): if x: return 1 return 0 return inner """ ) assert funcs["mod.py::outer"]["complexity"] == 1 assert funcs["mod.py::outer.inner"]["complexity"] == 2 @pytest.mark.parametrize( ("source", "typed"), [ ("def f(a: int) -> int:\n return a\n", True), ("def f(a) -> int:\n return a\n", False), ("def f(a: int):\n return a\n", False), ("def f(*a: int, **k: int) -> None:\n pass\n", True), ("def f(*a) -> None:\n pass\n", False), ("class C:\n def f(self, a: int) -> None:\n pass\n", True), ], ) def test_typed(self, source: str, typed: bool) -> None: [entry] = _functions(source).values() assert entry["typed"] is typed def test_private_names_and_what_they_contain_are_not_public(self) -> None: funcs = _functions( """ def _helper(): def inner(): pass class _Hidden: def method(self): pass class Shown: def _private(self): pass def public(self): pass """ ) public = {k for k, v in funcs.items() if v["public"]} assert public == {"mod.py::Shown.public"} def test_the_walker_yields_each_function_once_with_its_qualified_name(self) -> None: tree = ast.parse( "def top():\n pass\n\nclass C:\n def m(self):\n def inner():\n" " pass\n" ) walked = [ (name, node.name, in_class) for name, node, in_class, _ in qr.iter_python_functions(tree) ] assert walked == [("top", "top", False), ("C.m", "m", True), ("C.m.inner", "inner", False)] def test_a_function_inside_a_block_is_still_measured(self) -> None: funcs = _functions( """ def outer(flag): with lock(): def _stream(): if flag: return 1 return 0 return _stream class C: if True: def m(self): pass """ ) assert funcs["mod.py::outer._stream"]["complexity"] == 2 assert "mod.py::C.m" in funcs # ── pyproject settings ─────────────────────────────────────────────────── def test_relaxed_mypy_modules_are_the_overrides_that_loosen_strict() -> None: pyproject = { "tool": { "mypy": { "strict": True, "overrides": [ {"module": "napalm.*", "ignore_missing_imports": True}, {"module": ["pkg.old", "pkg.older"], "ignore_errors": True}, {"module": "pkg.legacy", "disallow_untyped_defs": False}, {"module": "pkg.loose", "strict": False}, ], } } } assert qr.relaxed_mypy_modules(pyproject) == [ "pkg.legacy", "pkg.loose", "pkg.old", "pkg.older", ] def test_coverage_floor_is_read_from_pyproject() -> None: assert qr.coverage_floor({"tool": {"coverage": {"report": {"fail_under": 63.5}}}}) == 63.5 assert qr.coverage_floor({}) == 0 # ── the ratchet against a repo ─────────────────────────────────────────── LONG = "def long(x: int) -> int:\n" + " x += 1\n" * 60 + " return x\n" def _repo(tmp_path: pathlib.Path, source: str, pyproject: str = "") -> pathlib.Path: (tmp_path / "pkg").mkdir() (tmp_path / "pkg" / "mod.py").write_text(source) (tmp_path / "pyproject.toml").write_text( '[project]\nname = "pkg"\n\n' + (pyproject or "[tool.coverage.report]\nfail_under = 50\n") ) return tmp_path def test_measure_reports_only_what_is_over_the_limit(tmp_path: pathlib.Path) -> None: repo = _repo( tmp_path, LONG + "\ndef ok(a: int) -> int:\n return a\n\ndef raw(a):\n pass\n" ) measured = qr.measure(repo, "pkg") assert measured["function_lines"] == {"pkg/mod.py::long": 62} assert measured["function_complexity"] == {} assert measured["python_file_lines"] == {} assert measured["untyped_public"] == ["pkg/mod.py::raw"] assert measured["coverage_floor"] == 50 def test_a_file_over_the_hard_maximum_is_measured(tmp_path: pathlib.Path) -> None: repo = _repo(tmp_path, "x = 1\n" * 1001) assert qr.measure(repo, "pkg")["python_file_lines"] == {"pkg/mod.py": 1001} def test_without_a_baseline_everything_over_the_limit_is_new(tmp_path: pathlib.Path) -> None: repo = _repo(tmp_path, LONG) problems = qr.problems(qr.measure(repo, "pkg"), {}) assert problems == ["function_lines: pkg/mod.py::long = 62 (new)"] def test_a_recorded_excess_may_stay_but_not_grow(tmp_path: pathlib.Path) -> None: repo = _repo(tmp_path, LONG) measured = qr.measure(repo, "pkg") assert qr.problems(measured, {"function_lines": {"pkg/mod.py::long": 62}}) == [] assert qr.problems(measured, {"function_lines": {"pkg/mod.py::long": 61}}) == [ "function_lines: pkg/mod.py::long = 62 (recorded 61)" ] def test_a_new_untyped_public_function_is_a_problem(tmp_path: pathlib.Path) -> None: repo = _repo(tmp_path, "def raw(a):\n pass\n") assert qr.problems(qr.measure(repo, "pkg"), {"untyped_public": []}) == [ "untyped_public: pkg/mod.py::raw (new)" ] def test_a_new_relaxed_mypy_module_is_a_problem(tmp_path: pathlib.Path) -> None: pyproject = ( "[tool.mypy]\nstrict = true\n\n[[tool.mypy.overrides]]\n" 'module = "pkg.mod"\nignore_errors = true\n' ) repo = _repo(tmp_path, "", pyproject) assert qr.problems(qr.measure(repo, "pkg"), {}) == ["mypy_relaxed: pkg.mod (new)"] assert qr.problems(qr.measure(repo, "pkg"), {"mypy_relaxed": ["pkg.mod"]}) == [] def test_a_lowered_coverage_floor_is_a_problem(tmp_path: pathlib.Path) -> None: repo = _repo(tmp_path, "") assert qr.problems(qr.measure(repo, "pkg"), {"coverage_floor": 60}) == [ "coverage_floor: 50 (recorded 60)" ] def test_progress_shows_up_as_stale_entries(tmp_path: pathlib.Path) -> None: repo = _repo(tmp_path, "def ok(a: int) -> int:\n return a\n") baseline = { "function_lines": {"pkg/mod.py::gone": 70}, "untyped_public": ["pkg/mod.py::fixed"], "mypy_relaxed": ["pkg.mod"], "coverage_floor": 40, } assert qr.stale_entries(qr.measure(repo, "pkg"), baseline) == [ "function_lines: pkg/mod.py::gone", "untyped_public: pkg/mod.py::fixed", "mypy_relaxed: pkg.mod", "coverage_floor: 40 -> 50", ] def test_write_then_check_is_clean(tmp_path: pathlib.Path) -> None: repo = _repo(tmp_path, LONG) assert qr.run(repo, "pkg", write=True)[0] == 0 baseline = json.loads((repo / qr.BASELINE).read_text()) assert baseline["function_lines"] == {"pkg/mod.py::long": 62} assert qr.run(repo, "pkg") == (0, "within the baseline") def test_check_fails_on_a_problem_and_on_a_stale_entry(tmp_path: pathlib.Path) -> None: repo = _repo(tmp_path, LONG) (repo / "tests").mkdir() (repo / qr.BASELINE).write_text(json.dumps({"function_lines": {"pkg/mod.py::old": 99}})) code, text = qr.run(repo, "pkg") assert code == 1 assert "pkg/mod.py::long = 62 (new)" in text assert "stale: function_lines: pkg/mod.py::old" in text def test_a_package_that_does_not_exist_is_an_error_not_an_empty_baseline( tmp_path: pathlib.Path, ) -> None: """procurve's import package is napalm_procurve: guessed from the name, the ratchet measured nothing and wrote an empty baseline without a word.""" repo = _repo(tmp_path, LONG) with pytest.raises(FileNotFoundError, match="napalm_missing"): qr.run(repo, "napalm_missing", write=True) assert not (repo / qr.BASELINE).exists()