testrisk 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
testrisk/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ testrisk — find the code most likely to need better tests.
3
+
4
+ Reads coverage.py data, maps uncovered lines onto functions via the AST,
5
+ weights git-changed code and missing tests, and prints a prioritized list::
6
+
7
+ from testrisk import GapError, analyze
8
+
9
+ report = analyze(".")
10
+ for gap in report.gaps:
11
+ print(gap.qualname, gap.risk)
12
+ """
13
+
14
+ from .discover import find_repo_root
15
+ from .engine import Options, analyze
16
+ from .errors import GapError
17
+ from .models import Gap, GapReport, Risk
18
+ from .version import __version__
19
+
20
+ __author__ = "Karl Hill"
21
+ __license__ = "MIT"
22
+
23
+ __all__ = [
24
+ "Gap",
25
+ "GapError",
26
+ "GapReport",
27
+ "Options",
28
+ "Risk",
29
+ "__version__",
30
+ "analyze",
31
+ "find_repo_root",
32
+ ]
testrisk/__main__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Allow ``python -m testrisk`` and ``uv run testrisk`` (the package directory)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def _main() -> int:
10
+ if __package__ in {None, ""}:
11
+ root = Path(__file__).resolve().parent.parent
12
+ root_s = str(root)
13
+ if root_s not in sys.path:
14
+ sys.path.insert(0, root_s)
15
+ from testrisk.cli import main
16
+ else:
17
+ from .cli import main
18
+ return main()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ sys.exit(_main())
testrisk/ast_index.py ADDED
@@ -0,0 +1,108 @@
1
+ """Map source files to function/method spans and cyclomatic-ish complexity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ from .models import FunctionSpan
9
+
10
+ _NESTED = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
11
+
12
+
13
+ def index_file(path: Path, source: str | None = None) -> tuple[FunctionSpan, ...]:
14
+ """Return functions/methods in *path*, innermost-friendly for line mapping."""
15
+ try:
16
+ text = path.read_text(encoding="utf-8") if source is None else source
17
+ except (OSError, UnicodeDecodeError):
18
+ return ()
19
+ try:
20
+ tree = ast.parse(text, filename=str(path))
21
+ except SyntaxError:
22
+ return ()
23
+ spans: list[FunctionSpan] = []
24
+ _visit(tree.body, path, (), spans)
25
+ return tuple(spans)
26
+
27
+
28
+ def innermost(
29
+ line: int,
30
+ spans: tuple[FunctionSpan, ...] | list[FunctionSpan],
31
+ ) -> FunctionSpan | None:
32
+ """The tightest function/method containing *line*, if any."""
33
+ containing = [span for span in spans if span.start <= line <= span.end]
34
+ if not containing:
35
+ return None
36
+ return min(containing, key=lambda span: (span.end - span.start, -span.start))
37
+
38
+
39
+ def module_complexity(path: Path, source: str | None = None) -> int:
40
+ """Complexity of module-level control flow only (nested defs skipped)."""
41
+ try:
42
+ text = path.read_text(encoding="utf-8") if source is None else source
43
+ except (OSError, UnicodeDecodeError):
44
+ return 1
45
+ try:
46
+ tree = ast.parse(text, filename=str(path))
47
+ except SyntaxError:
48
+ return 1
49
+ return _complexity(tree)
50
+
51
+
52
+ def _visit(
53
+ nodes: list[ast.stmt],
54
+ path: Path,
55
+ prefix: tuple[str, ...],
56
+ spans: list[FunctionSpan],
57
+ ) -> None:
58
+ for node in nodes:
59
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
60
+ name = ".".join((*prefix, node.name))
61
+ start = node.lineno
62
+ end = node.end_lineno or node.lineno
63
+ spans.append(
64
+ FunctionSpan(
65
+ path=path,
66
+ name=name,
67
+ start=start,
68
+ end=end,
69
+ complexity=_complexity(node),
70
+ )
71
+ )
72
+ _visit(node.body, path, (*prefix, node.name), spans)
73
+ elif isinstance(node, ast.ClassDef):
74
+ _visit(node.body, path, (*prefix, node.name), spans)
75
+
76
+
77
+ def _complexity(node: ast.AST) -> int:
78
+ score = 1
79
+ for child in _iter_local(node):
80
+ match child:
81
+ case (
82
+ ast.If()
83
+ | ast.For()
84
+ | ast.AsyncFor()
85
+ | ast.While()
86
+ | ast.IfExp()
87
+ | ast.Assert()
88
+ | ast.ExceptHandler()
89
+ ):
90
+ score += 1
91
+ case ast.With() | ast.AsyncWith():
92
+ score += 1
93
+ case ast.BoolOp():
94
+ score += max(0, len(child.values) - 1)
95
+ case ast.comprehension():
96
+ score += 1 + len(child.ifs)
97
+ case ast.Match():
98
+ score += len(child.cases)
99
+ return score
100
+
101
+
102
+ def _iter_local(node: ast.AST):
103
+ """Walk *node*, skipping nested functions and classes."""
104
+ for child in ast.iter_child_nodes(node):
105
+ if isinstance(child, _NESTED):
106
+ continue
107
+ yield child
108
+ yield from _iter_local(child)
testrisk/cli.py ADDED
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ testrisk CLI — rank the highest-value Python test gaps.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import contextlib
10
+ import json
11
+ import os
12
+ import sys
13
+ import traceback
14
+
15
+ from .discover import resolve_repo_path
16
+ from .doctor import inspect_environment, print_doctor
17
+ from .engine import analyze
18
+ from .errors import GapError
19
+ from .models import ChangedCoverage
20
+ from .report import render_json, render_prompt, render_text
21
+ from .ui import configure_ui, get_ui
22
+ from .version import __version__
23
+
24
+
25
+ def _cli_dispatch() -> int:
26
+ parser = argparse.ArgumentParser(
27
+ prog="testrisk",
28
+ description="Rank the highest-value untested Python behavior",
29
+ formatter_class=argparse.RawDescriptionHelpFormatter,
30
+ epilog="""
31
+ Examples:
32
+ uvx testrisk # zero-install; rank gaps
33
+ testrisk # Use coverage.json / .coverage
34
+ testrisk --changed # Only gaps on git-changed lines
35
+ testrisk --top 10 # Limit the list
36
+ testrisk --file src/foo.py # Restrict to one file
37
+ testrisk --json # Machine-readable report
38
+ testrisk --prompt # Agent task from the evidence
39
+ testrisk --fail-under-changed 95 # CI gate on changed-line coverage
40
+ testrisk --doctor # Check coverage data and git
41
+ python -m testrisk --version # If testrisk is not on PATH
42
+ """,
43
+ )
44
+ parser.add_argument(
45
+ "--repo",
46
+ "-r",
47
+ default=".",
48
+ help="Path to repository (default: . — walk up for pyproject.toml / .git)",
49
+ )
50
+ parser.add_argument(
51
+ "--coverage",
52
+ "-c",
53
+ default=None,
54
+ metavar="PATH",
55
+ help="coverage.json, coverage.xml, or .coverage (default: search the repo)",
56
+ )
57
+ parser.add_argument(
58
+ "--changed",
59
+ action="store_true",
60
+ help="Only show gaps that intersect git-changed lines",
61
+ )
62
+ parser.add_argument(
63
+ "--base",
64
+ default=None,
65
+ metavar="REF",
66
+ help="Git ref to diff against (default: origin/main, then main, master, HEAD)",
67
+ )
68
+ parser.add_argument(
69
+ "--top",
70
+ "-n",
71
+ type=int,
72
+ default=10,
73
+ metavar="N",
74
+ help="How many gaps to show (default: 10; 0 means all)",
75
+ )
76
+ parser.add_argument(
77
+ "--file",
78
+ "-f",
79
+ dest="files",
80
+ action="append",
81
+ metavar="PATH",
82
+ help="Restrict ranking to this source file (repeatable)",
83
+ )
84
+ parser.add_argument(
85
+ "--json",
86
+ action="store_true",
87
+ help="Print the report as JSON",
88
+ )
89
+ parser.add_argument(
90
+ "--prompt",
91
+ action="store_true",
92
+ help="Print an evidence-based task for an AI coding agent",
93
+ )
94
+ parser.add_argument(
95
+ "--fail-under-changed",
96
+ type=float,
97
+ default=None,
98
+ metavar="PCT",
99
+ help="Exit 1 if changed-line coverage is below this percent",
100
+ )
101
+ parser.add_argument(
102
+ "--doctor",
103
+ action="store_true",
104
+ help="Check Python, coverage data, and git, then exit",
105
+ )
106
+ parser.add_argument(
107
+ "--quiet",
108
+ "-q",
109
+ action="store_true",
110
+ help="Print only the suggested next test file",
111
+ )
112
+ parser.add_argument(
113
+ "--color",
114
+ choices=["auto", "always", "never"],
115
+ default="auto",
116
+ help="Color output (default: auto; also NO_COLOR / FORCE_COLOR)",
117
+ )
118
+ parser.add_argument(
119
+ "--verbose",
120
+ action="store_true",
121
+ help="Print a traceback on unexpected errors",
122
+ )
123
+ parser.add_argument(
124
+ "--version",
125
+ action="version",
126
+ version=f"%(prog)s {__version__}",
127
+ )
128
+
129
+ args = parser.parse_args()
130
+ color = "never" if args.json or args.prompt else args.color
131
+ configure_ui(color=color, quiet=bool(args.quiet and not args.json and not args.prompt))
132
+ ui = get_ui()
133
+
134
+ if args.top < 0:
135
+ ui.error("--top must be >= 0")
136
+ return 2
137
+ if args.fail_under_changed is not None and not 0 <= args.fail_under_changed <= 100:
138
+ ui.error("--fail-under-changed must be between 0 and 100")
139
+ return 2
140
+
141
+ try:
142
+ repo_path = resolve_repo_path(args.repo)
143
+ except OSError as exc:
144
+ ui.error(f"Could not resolve path {args.repo!r}: {exc}")
145
+ return 1
146
+ if not repo_path.is_dir():
147
+ ui.error(f"Not a directory: {repo_path}")
148
+ return 1
149
+
150
+ if args.doctor:
151
+ return print_doctor(
152
+ inspect_environment(str(repo_path), args.coverage),
153
+ json_output=args.json,
154
+ )
155
+
156
+ try:
157
+ report = analyze(
158
+ root=repo_path,
159
+ coverage=args.coverage,
160
+ changed_only=args.changed,
161
+ base=args.base,
162
+ files=tuple(args.files or ()),
163
+ top=args.top,
164
+ )
165
+ except GapError as exc:
166
+ if args.json:
167
+ print(json.dumps({"error": str(exc).split("\n", 1)[0]}))
168
+ return 1
169
+ ui.error(str(exc))
170
+ return 1
171
+
172
+ if args.json:
173
+ render_json(report, prompt=args.prompt)
174
+ elif args.prompt:
175
+ render_prompt(report)
176
+ elif args.quiet:
177
+ if report.suggested_target is not None:
178
+ print(report.suggested_target.as_posix())
179
+ elif report.gaps:
180
+ print(report.gaps[0].suggested_test.as_posix())
181
+ else:
182
+ render_text(report)
183
+
184
+ if args.fail_under_changed is not None:
185
+ return _check_changed_floor(report.changed, args.fail_under_changed, args.json)
186
+ return 0
187
+
188
+
189
+ def _check_changed_floor(changed: ChangedCoverage | None, floor: float, json_output: bool) -> int:
190
+ if changed is None:
191
+ ui = get_ui()
192
+ msg = "Cannot enforce --fail-under-changed without git"
193
+ if json_output:
194
+ return 1
195
+ ui.error(msg)
196
+ return 1
197
+ if changed.statements == 0:
198
+ return 0
199
+ percent = changed.percent or 0.0
200
+ if percent + 1e-9 >= floor:
201
+ return 0
202
+ if not json_output:
203
+ get_ui().error(f"Changed-line coverage {_fmt(percent)} is below {_fmt(floor)}")
204
+ return 1
205
+
206
+
207
+ def _fmt(value: float) -> str:
208
+ return f"{value:.1f}%"
209
+
210
+
211
+ def _line_buffer_stdio() -> None:
212
+ """Keep banners ahead of following output when stdout is a pipe."""
213
+ for stream in (sys.stdout, sys.stderr):
214
+ reconfigure = getattr(stream, "reconfigure", None)
215
+ if reconfigure is None:
216
+ continue
217
+ with contextlib.suppress(OSError, ValueError):
218
+ reconfigure(line_buffering=True)
219
+
220
+
221
+ def main() -> int:
222
+ _line_buffer_stdio()
223
+ try:
224
+ return _cli_dispatch()
225
+ except KeyboardInterrupt:
226
+ get_ui().error("Interrupted.")
227
+ return 130
228
+ except BrokenPipeError:
229
+ with contextlib.suppress(OSError):
230
+ sys.stdout.close()
231
+ return 0
232
+ except Exception as exc:
233
+ ui = get_ui()
234
+ ui.error("testrisk hit an unexpected error.")
235
+ ui.error(f"{type(exc).__name__}: {exc}")
236
+ if "--verbose" in sys.argv or os.environ.get("TESTRISK_DEBUG"):
237
+ traceback.print_exc()
238
+ else:
239
+ ui.note(
240
+ "Re-run with --verbose or TESTRISK_DEBUG=1 for a traceback.",
241
+ persist=True,
242
+ )
243
+ return 1
244
+
245
+
246
+ if __name__ == "__main__":
247
+ raise SystemExit(main())