acquit 0.0.1__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.
acquit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Acquit: provably skip unaffected pytest tests, with evidence for every skip."""
2
+
3
+ __version__ = "0.0.1"
acquit/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Make `python -m acquit` work; the study runner invokes acquit this way."""
2
+
3
+ from acquit.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
acquit/cli.py ADDED
@@ -0,0 +1,239 @@
1
+ """Command line entry point.
2
+
3
+ Every failure path converges on a run-all report. The tool may only be wrong
4
+ in the safe direction.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import stat
10
+ import sys
11
+ import uuid
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from acquit import __version__
17
+ from acquit.constants import (
18
+ DEFAULT_REPORT_FILE,
19
+ DEFAULT_SELECTION_FILE,
20
+ DEFAULT_WITNESSES_FILE,
21
+ )
22
+ from acquit.errors import AcquitError, ExitCode
23
+ from acquit.explain import explain_lines
24
+ from acquit.gh.comment import run_comment
25
+ from acquit.gh.outputs import run_ci_outputs
26
+ from acquit.graph.model import NodeKind
27
+ from acquit.pipeline import SelectResult, run_select, snapshot_working_tree
28
+ from acquit.policy.model import Finding, RuleId, Scope, ScopeKind
29
+ from acquit.replay import run_replay
30
+ from acquit.report import (
31
+ RunInfo,
32
+ SelectionMode,
33
+ build_report,
34
+ build_run_all_report,
35
+ build_run_all_selection,
36
+ build_selection_doc,
37
+ build_witnesses_doc,
38
+ to_canonical_json,
39
+ )
40
+
41
+
42
+ def _build_parser() -> argparse.ArgumentParser:
43
+ parser = argparse.ArgumentParser(prog="acquit")
44
+ parser.add_argument("--version", action="version", version=f"acquit {__version__}")
45
+ subcommands = parser.add_subparsers(dest="command", required=True)
46
+
47
+ select = subcommands.add_parser("select", help="decide which tests must run for a diff")
48
+ select.add_argument("--base", required=True, help="base git ref")
49
+ select.add_argument("--head", help="head git ref, defaults to the working tree")
50
+ select.add_argument("--report", default=DEFAULT_REPORT_FILE, help="report output path")
51
+ select.add_argument("--selection", default=DEFAULT_SELECTION_FILE, help="selection output path")
52
+ select.add_argument("--witnesses", default=DEFAULT_WITNESSES_FILE, help="witnesses output path")
53
+ select.add_argument("--durations", help="json file mapping test paths to seconds")
54
+
55
+ subcommands.add_parser("analyze", help="build the dependency graph and print its health")
56
+
57
+ explain = subcommands.add_parser("explain", help="explain the decision for one test file")
58
+ explain.add_argument("test", help="repo-relative test file path")
59
+ explain.add_argument("--base", required=True, help="base git ref")
60
+ explain.add_argument("--head", help="head git ref, defaults to the working tree")
61
+
62
+ replay = subcommands.add_parser("replay", help="re-verify the witnesses behind a report")
63
+ replay.add_argument("report", help="path to an acquit report file")
64
+ replay.add_argument("--witnesses", default=DEFAULT_WITNESSES_FILE, help="witnesses file path")
65
+ replay.add_argument("--selection", help="selection file to cross-check against the report")
66
+
67
+ comment = subcommands.add_parser(
68
+ "comment", help="post or update the sticky PR comment for a report; never fails CI"
69
+ )
70
+ comment.add_argument("report", help="path to an acquit report file")
71
+ comment.add_argument("--pr", type=int, help="pull request number; otherwise from GITHUB_REF")
72
+
73
+ # A separate subcommand rather than a comment flag: the action calls it on
74
+ # every run, comment or not, and its contract (runner files) is different.
75
+ ci_outputs = subcommands.add_parser(
76
+ "ci-outputs", help="write GitHub action outputs and a step summary; never fails CI"
77
+ )
78
+ ci_outputs.add_argument("report", help="path to an acquit report file")
79
+ ci_outputs.add_argument("selection", help="path to the selection file pytest will obey")
80
+ return parser
81
+
82
+
83
+ def _now() -> str:
84
+ return datetime.now(UTC).isoformat(timespec="seconds")
85
+
86
+
87
+ def _load_durations(path_arg: str | None) -> dict[str, float] | None:
88
+ if path_arg is None:
89
+ return None
90
+ data = json.loads(Path(path_arg).read_text(encoding="utf-8"))
91
+ if not isinstance(data, dict):
92
+ raise AcquitError(f"{path_arg}: durations must be a json object")
93
+ durations: dict[str, float] = {}
94
+ for key, value in data.items():
95
+ if (
96
+ not isinstance(key, str)
97
+ or isinstance(value, bool)
98
+ or not isinstance(value, int | float)
99
+ ):
100
+ raise AcquitError(f"{path_arg}: durations must map test paths to seconds")
101
+ durations[key] = float(value)
102
+ return durations
103
+
104
+
105
+ def _summary(result: SelectResult) -> str:
106
+ decision = result.decision
107
+ total = sum(1 for node in result.head.graph.nodes.values() if node.kind is NodeKind.TEST)
108
+ if decision.mode is SelectionMode.SELECTIVE:
109
+ return (
110
+ f"acquit: selective: {len(decision.selected)} selected, "
111
+ f"{len(decision.always_run)} always-run, "
112
+ f"{len(decision.skipped)} skipped of {total} tests"
113
+ )
114
+ findings = result.outcome.findings
115
+ if findings:
116
+ top = "; ".join(f"{finding.rule}:{finding.subject}" for finding in findings[:3])
117
+ if len(findings) > 3:
118
+ top += f" and {len(findings) - 3} more"
119
+ else:
120
+ top = "no test could be proven unaffected"
121
+ return f"acquit: run-all: {top} ({total} tests)"
122
+
123
+
124
+ def _write_document(path: Path, document: dict[str, Any]) -> None:
125
+ """Write one document atomically: temp file in place, then replace."""
126
+ tmp = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
127
+ try:
128
+ tmp.write_text(to_canonical_json(document), encoding="utf-8")
129
+ try:
130
+ tmp.replace(path)
131
+ except PermissionError:
132
+ # Windows refuses to replace a read-only target; clear the bit.
133
+ path.chmod(stat.S_IWRITE)
134
+ tmp.replace(path)
135
+ except BaseException:
136
+ tmp.unlink(missing_ok=True)
137
+ raise
138
+
139
+
140
+ def _run_select(args: argparse.Namespace) -> int:
141
+ created_at = _now()
142
+ durations = _load_durations(args.durations)
143
+ result = run_select(args.base, args.head, Path.cwd())
144
+ graph_hash = result.head.graph.graph_hash
145
+ report = build_report(result, created_at=created_at, durations=durations)
146
+ selection = build_selection_doc(
147
+ result.decision, graph_hash, result.head_sha, result.tree_fingerprint
148
+ )
149
+ witnesses = build_witnesses_doc(result.decision, graph_hash)
150
+ _write_document(Path(args.report), report)
151
+ _write_document(Path(args.selection), selection)
152
+ _write_document(Path(args.witnesses), witnesses)
153
+ print(_summary(result))
154
+ return ExitCode.OK
155
+
156
+
157
+ def _run_analyze() -> int:
158
+ snapshot = snapshot_working_tree(Path.cwd())
159
+ graph = snapshot.graph
160
+ health = {
161
+ "files": len(snapshot.files),
162
+ "nodes": graph.digraph.num_nodes(),
163
+ "edges": graph.digraph.num_edges(),
164
+ "tests": sum(1 for node in graph.nodes.values() if node.kind is NodeKind.TEST),
165
+ "tainted": sum(1 for node in graph.nodes.values() if node.tainted),
166
+ "roots": list(snapshot.index.roots),
167
+ "graph_hash": graph.graph_hash,
168
+ }
169
+ print(to_canonical_json(health), end="")
170
+ return ExitCode.OK
171
+
172
+
173
+ def _run_explain(args: argparse.Namespace) -> int:
174
+ result = run_select(args.base, args.head, Path.cwd())
175
+ lines, code = explain_lines(args.test, result)
176
+ stream = sys.stdout if code is ExitCode.OK else sys.stderr
177
+ for line in lines:
178
+ print(line, file=stream)
179
+ return code
180
+
181
+
182
+ def _run_replay(args: argparse.Namespace) -> int:
183
+ selection = None if args.selection is None else Path(args.selection)
184
+ lines, code = run_replay(Path(args.report), Path(args.witnesses), selection, Path.cwd())
185
+ stream = sys.stdout if code is ExitCode.OK else sys.stderr
186
+ for line in lines:
187
+ print(line, file=stream)
188
+ return code
189
+
190
+
191
+ def _write_failure_docs(args: argparse.Namespace, error: Exception) -> None:
192
+ finding = Finding(
193
+ rule=RuleId.INTERNAL_ERROR,
194
+ scope=Scope(kind=ScopeKind.GLOBAL),
195
+ subject="acquit",
196
+ reason=str(error) or type(error).__name__,
197
+ )
198
+ run = RunInfo(base_sha=args.base, head_sha=args.head, created_at=_now())
199
+ report = build_run_all_report(run, findings=[finding])
200
+ # The selection is what pytest obeys, so it must convert to run-all first,
201
+ # and a failure to convert it must be loud, never suppressed.
202
+ for path, document in ((args.selection, build_run_all_selection()), (args.report, report)):
203
+ try:
204
+ _write_document(Path(path), document)
205
+ except Exception as write_error:
206
+ print(
207
+ f"acquit: FAILED to write fail-closed document {path}: {write_error}",
208
+ file=sys.stderr,
209
+ )
210
+
211
+
212
+ def main(argv: list[str] | None = None) -> int:
213
+ parser = _build_parser()
214
+ args = parser.parse_args(argv)
215
+ try:
216
+ if args.command == "select":
217
+ return _run_select(args)
218
+ if args.command == "analyze":
219
+ return _run_analyze()
220
+ if args.command == "explain":
221
+ return _run_explain(args)
222
+ if args.command == "comment":
223
+ return run_comment(Path(args.report), args.pr)
224
+ if args.command == "ci-outputs":
225
+ return run_ci_outputs(Path(args.report), Path(args.selection))
226
+ return _run_replay(args)
227
+ except Exception as error: # fail closed on anything unexpected
228
+ if args.command in ("comment", "ci-outputs"):
229
+ # Delivery must never fail CI, even if its own guard rails break.
230
+ print(f"acquit: warning: {args.command} skipped: {error}", file=sys.stderr)
231
+ return ExitCode.OK
232
+ print(f"acquit: internal error, run all tests: {error}", file=sys.stderr)
233
+ if args.command == "select":
234
+ _write_failure_docs(args, error)
235
+ return ExitCode.INTERNAL
236
+
237
+
238
+ if __name__ == "__main__":
239
+ raise SystemExit(main())
acquit/config.py ADDED
@@ -0,0 +1,105 @@
1
+ """Acquit's own configuration.
2
+
3
+ Sources, first hit wins: .acquit.toml at the repo root (top-level keys), then
4
+ [tool.acquit] in pyproject.toml. Missing files mean defaults. Unknown keys are
5
+ rejected: a typo in a soundness-sensitive config must not be silently ignored.
6
+ """
7
+
8
+ import tomllib
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from acquit.errors import PolicyError
14
+
15
+ _KNOWN_KEYS = frozenset({"roots", "assume_inert", "waive"})
16
+ _WAIVER_KEYS = ("rule", "glob", "justification")
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Waiver:
21
+ rule: str
22
+ glob: str
23
+ # Mandatory: a waiver nobody can justify is not reviewable.
24
+ justification: str
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class AcquitConfig:
29
+ roots: tuple[str, ...] = ()
30
+ # Globs of resource files the user vouches never affect tests.
31
+ assume_inert: tuple[str, ...] = ()
32
+ waivers: tuple[Waiver, ...] = ()
33
+
34
+
35
+ def load_config(repo_root: Path) -> AcquitConfig:
36
+ """Load configuration from repo_root, falling back to defaults.
37
+
38
+ Raises PolicyError on unreadable TOML, unknown keys, or malformed waivers.
39
+ """
40
+ acquit_toml = repo_root / ".acquit.toml"
41
+ if acquit_toml.is_file():
42
+ return _parse(_read_toml(acquit_toml), source=".acquit.toml")
43
+ pyproject = repo_root / "pyproject.toml"
44
+ if pyproject.is_file():
45
+ tool = _read_toml(pyproject).get("tool")
46
+ section = tool.get("acquit") if isinstance(tool, dict) else None
47
+ if section is not None:
48
+ if not isinstance(section, dict):
49
+ raise PolicyError("pyproject.toml: [tool.acquit] must be a table")
50
+ return _parse(section, source="pyproject.toml [tool.acquit]")
51
+ return AcquitConfig()
52
+
53
+
54
+ def _read_toml(path: Path) -> dict[str, Any]:
55
+ try:
56
+ with path.open("rb") as handle:
57
+ return tomllib.load(handle)
58
+ except OSError as error:
59
+ raise PolicyError(f"{path.name}: cannot read: {error}") from error
60
+ except tomllib.TOMLDecodeError as error:
61
+ raise PolicyError(f"{path.name}: invalid TOML: {error}") from error
62
+
63
+
64
+ def _parse(data: dict[str, Any], source: str) -> AcquitConfig:
65
+ unknown = sorted(set(data) - _KNOWN_KEYS)
66
+ if unknown:
67
+ raise PolicyError(f"{source}: unknown key(s): {', '.join(unknown)}")
68
+ return AcquitConfig(
69
+ roots=_string_tuple(data.get("roots", []), key="roots", source=source),
70
+ assume_inert=_string_tuple(data.get("assume_inert", []), key="assume_inert", source=source),
71
+ waivers=_parse_waivers(data.get("waive", []), source=source),
72
+ )
73
+
74
+
75
+ def _string_tuple(value: Any, key: str, source: str) -> tuple[str, ...]:
76
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
77
+ raise PolicyError(f"{source}: {key!r} must be an array of strings")
78
+ return tuple(value)
79
+
80
+
81
+ def _parse_waivers(value: Any, source: str) -> tuple[Waiver, ...]:
82
+ if not isinstance(value, list):
83
+ raise PolicyError(f"{source}: 'waive' must be an array of tables")
84
+ waivers: list[Waiver] = []
85
+ for position, entry in enumerate(value, start=1):
86
+ label = f"{source}: waive entry {position}"
87
+ if not isinstance(entry, dict):
88
+ raise PolicyError(f"{label} must be a table")
89
+ if isinstance(entry.get("rule"), str):
90
+ label = f"{label} (rule {entry['rule']})"
91
+ unknown = sorted(set(entry) - set(_WAIVER_KEYS))
92
+ if unknown:
93
+ raise PolicyError(f"{label}: unknown key(s): {', '.join(unknown)}")
94
+ missing = [key for key in _WAIVER_KEYS if key not in entry]
95
+ if missing:
96
+ raise PolicyError(f"{label}: missing key(s): {', '.join(missing)}")
97
+ for key in _WAIVER_KEYS:
98
+ if not isinstance(entry[key], str):
99
+ raise PolicyError(f"{label}: {key!r} must be a string")
100
+ if not entry["justification"].strip():
101
+ raise PolicyError(f"{label}: justification must not be empty")
102
+ waivers.append(
103
+ Waiver(rule=entry["rule"], glob=entry["glob"], justification=entry["justification"])
104
+ )
105
+ return tuple(waivers)
acquit/constants.py ADDED
@@ -0,0 +1,19 @@
1
+ """Shared constants. Values that appear in more than one module live here."""
2
+
3
+ from typing import Final
4
+
5
+ REPORT_SCHEMA: Final = "acquit/report-v1"
6
+ SELECTION_SCHEMA: Final = "acquit/selection-v2"
7
+ WITNESSES_SCHEMA: Final = "acquit/witnesses-v1"
8
+
9
+ ENV_SELECTION_FILE: Final = "ACQUIT_SELECTION_FILE"
10
+ ENV_CACHE_DIR: Final = "ACQUIT_CACHE_DIR"
11
+
12
+ DEFAULT_REPORT_FILE: Final = "acquit-report.json"
13
+ DEFAULT_SELECTION_FILE: Final = "acquit-selection.json"
14
+ DEFAULT_WITNESSES_FILE: Final = "acquit-witnesses.json"
15
+
16
+ COMMENT_MARKER: Final = "<!-- acquit-report -->"
17
+
18
+ # Refuse selection documents larger than this before reading them.
19
+ SELECTION_SIZE_CAP: Final = 5 * 1024 * 1024
acquit/errors.py ADDED
@@ -0,0 +1,38 @@
1
+ """Error taxonomy and process exit codes.
2
+
3
+ User-facing failures must degrade to a fail-closed report, never a bare traceback.
4
+ The CLI catches AcquitError (and everything else) and converts it to a run-all decision.
5
+ """
6
+
7
+ from enum import IntEnum
8
+
9
+
10
+ class ExitCode(IntEnum):
11
+ OK = 0
12
+ USAGE = 2
13
+ INTERNAL = 3
14
+ REPLAY_MISMATCH = 4
15
+
16
+
17
+ class AcquitError(Exception):
18
+ """Base for all errors raised by acquit."""
19
+
20
+
21
+ class GraphError(AcquitError):
22
+ """The dependency graph could not be built."""
23
+
24
+
25
+ class ResolutionError(AcquitError):
26
+ """An import statement could not be resolved."""
27
+
28
+
29
+ class PolicyError(AcquitError):
30
+ """The policy engine could not evaluate its rules."""
31
+
32
+
33
+ class VcsError(AcquitError):
34
+ """Git information (refs, diffs, blobs) could not be obtained."""
35
+
36
+
37
+ class ParseFailure(GraphError):
38
+ """A source file could not be parsed into an AST."""
acquit/explain.py ADDED
@@ -0,0 +1,104 @@
1
+ """Explanations for individual test decisions.
2
+
3
+ Pure rendering over a completed SelectResult: no git, no filesystem. The CLI
4
+ runs the pipeline and hands the outcome here.
5
+ """
6
+
7
+ from acquit.errors import ExitCode
8
+ from acquit.graph.model import BuiltGraph, NodeKind
9
+ from acquit.pipeline import SelectResult
10
+ from acquit.policy.model import ScopeKind
11
+
12
+ _REACHABLE_PREFIX = "reachable-from:"
13
+
14
+
15
+ def _dependency_path(graph: BuiltGraph, src: str, dst: str) -> tuple[str, ...] | None:
16
+ start = graph.index_of.get(src)
17
+ goal = graph.index_of.get(dst)
18
+ if start is None or goal is None:
19
+ return None
20
+ if start == goal:
21
+ return (src,)
22
+ parent: dict[int, int] = {}
23
+ seen = {start}
24
+ frontier = [start]
25
+ while frontier:
26
+ upcoming: list[int] = []
27
+ for node in frontier:
28
+ # Neighbors sorted by path make the BFS tie-break deterministic.
29
+ neighbors = sorted(
30
+ graph.digraph.successor_indices(node), key=lambda index: graph.digraph[index].path
31
+ )
32
+ for neighbor in neighbors:
33
+ if neighbor in seen:
34
+ continue
35
+ seen.add(neighbor)
36
+ parent[neighbor] = node
37
+ if neighbor == goal:
38
+ return _reconstruct(graph, parent, start, goal)
39
+ upcoming.append(neighbor)
40
+ frontier = upcoming
41
+ return None
42
+
43
+
44
+ def _reconstruct(
45
+ graph: BuiltGraph, parent: dict[int, int], start: int, goal: int
46
+ ) -> tuple[str, ...]:
47
+ chain = [goal]
48
+ while chain[-1] != start:
49
+ chain.append(parent[chain[-1]])
50
+ return tuple(graph.digraph[index].path for index in reversed(chain))
51
+
52
+
53
+ def _explain_skipped(path: str, witness_id: str, result: SelectResult) -> tuple[str, ...]:
54
+ witness = next(w for w in result.decision.witnesses if w.id == witness_id)
55
+ closure = result.decision.closures[witness.closure_hash]
56
+ changed = ", ".join(witness.changed) if witness.changed else "(empty)"
57
+ return (
58
+ f"{path}: skipped",
59
+ f" witness: {witness.id}",
60
+ f" claim: {witness.claim}",
61
+ f" closure: {len(closure)} files, hash {witness.closure_hash}",
62
+ f" changed: {changed}",
63
+ )
64
+
65
+
66
+ def _explain_selected(path: str, reasons: tuple[str, ...], graph: BuiltGraph) -> tuple[str, ...]:
67
+ lines = [f"{path}: selected"]
68
+ for reason in reasons:
69
+ lines.append(f" reason: {reason}")
70
+ if not reason.startswith(_REACHABLE_PREFIX):
71
+ continue
72
+ target = reason.removeprefix(_REACHABLE_PREFIX)
73
+ chain = _dependency_path(graph, path, target)
74
+ if chain is None:
75
+ lines.append(" (reachable only in the base graph)")
76
+ else:
77
+ lines.append(" " + " imports ".join(chain))
78
+ return tuple(lines)
79
+
80
+
81
+ def explain_lines(test: str, result: SelectResult) -> tuple[tuple[str, ...], ExitCode]:
82
+ """Explain why one test was selected, skipped, or forced to run."""
83
+ path = test.replace("\\", "/").removeprefix("./")
84
+ node = result.head.graph.nodes.get(path)
85
+ if node is None or node.kind is not NodeKind.TEST:
86
+ return ((f"acquit: {path!r} is not a known test file at head",), ExitCode.USAGE)
87
+
88
+ global_findings = [f for f in result.outcome.findings if f.scope.kind is ScopeKind.GLOBAL]
89
+ if global_findings:
90
+ lines = [f"{path}: runs; global findings force the full suite:"]
91
+ lines.extend(f" {f.rule} {f.subject}: {f.reason}" for f in global_findings)
92
+ return (tuple(lines), ExitCode.OK)
93
+
94
+ for skipped in result.decision.skipped:
95
+ if skipped.path == path:
96
+ return (_explain_skipped(path, skipped.witness_id, result), ExitCode.OK)
97
+ for always in result.decision.always_run:
98
+ if always.path == path:
99
+ return ((f"{path}: always runs", f" finding: {always.finding}"), ExitCode.OK)
100
+ for selected in result.decision.selected:
101
+ if selected.path == path:
102
+ return (_explain_selected(path, selected.reasons, result.head.graph), ExitCode.OK)
103
+ # Unreachable when the decision covers every head test; answer safely anyway.
104
+ return ((f"{path}: runs (not covered by the decision, run-all default)",), ExitCode.OK)
acquit/gh/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """GitHub delivery: the sticky PR comment and the action's runner-file outputs.
2
+
3
+ Everything in this package is best-effort by design. A delivery failure is a
4
+ warning on stderr and a zero exit, because reporting must never fail CI; the
5
+ fail-closed guarantees all live upstream in selection and replay.
6
+ """