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 +32 -0
- testrisk/__main__.py +22 -0
- testrisk/ast_index.py +108 -0
- testrisk/cli.py +247 -0
- testrisk/coverage_data.py +303 -0
- testrisk/discover.py +117 -0
- testrisk/doctor.py +93 -0
- testrisk/engine.py +279 -0
- testrisk/errors.py +5 -0
- testrisk/gitdiff.py +135 -0
- testrisk/models.py +189 -0
- testrisk/py.typed +0 -0
- testrisk/rank.py +67 -0
- testrisk/report.py +123 -0
- testrisk/tests_map.py +91 -0
- testrisk/ui.py +133 -0
- testrisk/version.py +13 -0
- testrisk-1.0.0.dist-info/METADATA +266 -0
- testrisk-1.0.0.dist-info/RECORD +22 -0
- testrisk-1.0.0.dist-info/WHEEL +4 -0
- testrisk-1.0.0.dist-info/entry_points.txt +2 -0
- testrisk-1.0.0.dist-info/licenses/LICENSE +21 -0
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())
|