Skip to content

Commit dc5f798

Browse files
committed
Починил тест
1 parent 827003f commit dc5f798

2 files changed

Lines changed: 235 additions & 0 deletions

File tree

scripts/check_interrogate_gate.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Diff-aware interrogate coverage gate."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
import subprocess
8+
from collections.abc import Mapping, Sequence
9+
from dataclasses import dataclass
10+
from pathlib import Path
11+
from typing import cast
12+
13+
from interrogate.config import InterrogateConfig
14+
from interrogate.coverage import InterrogateCoverage
15+
16+
DEFAULT_NEW_MODULE_BASELINE = 100.0
17+
18+
19+
@dataclass(frozen=True, slots=True)
20+
class CoverageRegression:
21+
"""Docstring coverage regression for a changed module."""
22+
23+
path: str
24+
baseline: float
25+
current: float
26+
27+
28+
def parse_args() -> argparse.Namespace:
29+
"""Parse CLI arguments."""
30+
31+
parser = argparse.ArgumentParser(
32+
description="Проверить interrogate coverage только для измененных модулей.",
33+
)
34+
parser.add_argument(
35+
"--base-ref",
36+
default="origin/main",
37+
help="Git ref, относительно которого определяется список измененных файлов.",
38+
)
39+
parser.add_argument(
40+
"--baseline",
41+
type=Path,
42+
default=Path(".interrogate-baseline"),
43+
help="JSON-файл с baseline coverage по модулям.",
44+
)
45+
parser.add_argument(
46+
"--package",
47+
default="avito",
48+
help="Пакет, для которого применяется diff gate.",
49+
)
50+
return parser.parse_args()
51+
52+
53+
def main() -> int:
54+
"""Run interrogate diff gate CLI."""
55+
56+
args = parse_args()
57+
root = Path.cwd()
58+
baseline = load_baseline(root / args.baseline)
59+
changed_modules = changed_python_modules(root, args.base_ref, args.package)
60+
61+
if not changed_modules:
62+
print("Interrogate diff gate: changed modules=0, regressions=0")
63+
return 0
64+
65+
current = measure_module_coverage(root, changed_modules)
66+
regressions = find_regressions(changed_modules, baseline, current)
67+
print(render_report(changed_modules, baseline, current, regressions), end="")
68+
return 1 if regressions else 0
69+
70+
71+
def load_baseline(path: Path) -> Mapping[str, float]:
72+
"""Load per-module interrogate baseline from JSON."""
73+
74+
payload = json.loads(path.read_text(encoding="utf-8"))
75+
if not isinstance(payload, dict):
76+
raise ValueError(f"Некорректный baseline: {path}")
77+
modules = payload.get("modules")
78+
if not isinstance(modules, dict):
79+
raise ValueError(f"В baseline нет объекта modules: {path}")
80+
return {
81+
str(module_path): float(score)
82+
for module_path, score in cast(Mapping[object, object], modules).items()
83+
}
84+
85+
86+
def changed_python_modules(root: Path, base_ref: str, package: str) -> tuple[str, ...]:
87+
"""Return changed Python modules under package relative to repository root."""
88+
89+
command = [
90+
"git",
91+
"diff",
92+
"--name-only",
93+
"--diff-filter=ACMR",
94+
f"{base_ref}...HEAD",
95+
]
96+
result = subprocess.run(
97+
command,
98+
cwd=root,
99+
check=False,
100+
capture_output=True,
101+
text=True,
102+
)
103+
if result.returncode != 0:
104+
message = result.stderr.strip() or result.stdout.strip()
105+
raise RuntimeError(f"Не удалось получить diff относительно {base_ref}: {message}")
106+
107+
package_prefix = f"{package.rstrip('/')}/"
108+
modules = []
109+
for raw_path in result.stdout.splitlines():
110+
path = raw_path.strip()
111+
module_path = root / path
112+
if path.startswith(package_prefix) and path.endswith(".py") and module_path.is_file():
113+
modules.append(path)
114+
return tuple(sorted(set(modules)))
115+
116+
117+
def measure_module_coverage(root: Path, modules: Sequence[str]) -> Mapping[str, float]:
118+
"""Measure current interrogate coverage for selected modules."""
119+
120+
paths = [str(root / module_path) for module_path in modules]
121+
coverage = InterrogateCoverage(
122+
paths=paths,
123+
conf=InterrogateConfig(fail_under=0.0),
124+
)
125+
results = coverage.get_coverage()
126+
127+
scores: dict[str, float] = {}
128+
for file_result in results.file_results:
129+
module_path = _relative_path(Path(file_result.filename), root)
130+
scores[module_path] = round(file_result.perc_covered, 0)
131+
return scores
132+
133+
134+
def find_regressions(
135+
changed_modules: Sequence[str],
136+
baseline: Mapping[str, float],
137+
current: Mapping[str, float],
138+
) -> tuple[CoverageRegression, ...]:
139+
"""Return modules whose current coverage is below baseline."""
140+
141+
regressions: list[CoverageRegression] = []
142+
for module_path in changed_modules:
143+
baseline_score = baseline.get(module_path, DEFAULT_NEW_MODULE_BASELINE)
144+
current_score = current[module_path]
145+
if current_score < baseline_score:
146+
regressions.append(
147+
CoverageRegression(
148+
path=module_path,
149+
baseline=baseline_score,
150+
current=current_score,
151+
)
152+
)
153+
return tuple(regressions)
154+
155+
156+
def render_report(
157+
changed_modules: Sequence[str],
158+
baseline: Mapping[str, float],
159+
current: Mapping[str, float],
160+
regressions: Sequence[CoverageRegression],
161+
) -> str:
162+
"""Render human-readable gate report."""
163+
164+
lines = [
165+
(
166+
"Interrogate diff gate: "
167+
f"changed modules={len(changed_modules)}, regressions={len(regressions)}"
168+
)
169+
]
170+
for module_path in changed_modules:
171+
baseline_score = baseline.get(module_path, DEFAULT_NEW_MODULE_BASELINE)
172+
current_score = current[module_path]
173+
status = "FAIL" if current_score < baseline_score else "OK"
174+
lines.append(
175+
f"{status}: {module_path}: current={current_score:.0f}%, "
176+
f"baseline={baseline_score:.0f}%"
177+
)
178+
return "\n".join(lines) + "\n"
179+
180+
181+
def _relative_path(path: Path, root: Path) -> str:
182+
try:
183+
return str(path.resolve().relative_to(root.resolve()))
184+
except ValueError:
185+
return str(path)
186+
187+
188+
if __name__ == "__main__":
189+
raise SystemExit(main())

tests/test_interrogate_gate.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Tests for interrogate diff gate helpers."""
2+
3+
from __future__ import annotations
4+
5+
from check_interrogate_gate import find_regressions, render_report
6+
7+
8+
def test_find_regressions_reports_changed_module_below_baseline() -> None:
9+
"""Changed modules below their baseline fail the gate."""
10+
11+
regressions = find_regressions(
12+
changed_modules=("avito/client.py", "avito/config.py"),
13+
baseline={"avito/client.py": 92.0, "avito/config.py": 83.0},
14+
current={"avito/client.py": 91.0, "avito/config.py": 83.0},
15+
)
16+
17+
assert len(regressions) == 1
18+
assert regressions[0].path == "avito/client.py"
19+
20+
21+
def test_find_regressions_requires_new_modules_to_be_fully_documented() -> None:
22+
"""New modules without a baseline use a full-coverage target."""
23+
24+
regressions = find_regressions(
25+
changed_modules=("avito/new_domain/domain.py",),
26+
baseline={},
27+
current={"avito/new_domain/domain.py": 99.0},
28+
)
29+
30+
assert len(regressions) == 1
31+
assert regressions[0].baseline == 100.0
32+
33+
34+
def test_render_report_includes_ok_and_fail_statuses() -> None:
35+
"""Gate report lists every changed module with status."""
36+
37+
report = render_report(
38+
changed_modules=("avito/client.py", "avito/config.py"),
39+
baseline={"avito/client.py": 92.0, "avito/config.py": 83.0},
40+
current={"avito/client.py": 91.0, "avito/config.py": 83.0},
41+
regressions=(),
42+
)
43+
44+
assert "Interrogate diff gate: changed modules=2" in report
45+
assert "FAIL: avito/client.py: current=91%, baseline=92%" in report
46+
assert "OK: avito/config.py: current=83%, baseline=83%" in report

0 commit comments

Comments
 (0)