gitgrip 1.5.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.
- gitgrip-1.5.0.dist-info/METADATA +13 -0
- gitgrip-1.5.0.dist-info/RECORD +80 -0
- gitgrip-1.5.0.dist-info/WHEEL +5 -0
- gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
- gitgrip-1.5.0.dist-info/top_level.txt +2 -0
- gr2/__init__.py +0 -0
- gr2/overlay/__init__.py +6 -0
- gr2/overlay/activate.py +196 -0
- gr2/overlay/agent_manifest.py +138 -0
- gr2/overlay/cli.py +181 -0
- gr2/overlay/cross_repo.py +124 -0
- gr2/overlay/drivers.py +113 -0
- gr2/overlay/introspection.py +155 -0
- gr2/overlay/language_drivers.py +115 -0
- gr2/overlay/objects.py +412 -0
- gr2/overlay/perf.py +251 -0
- gr2/overlay/refs.py +36 -0
- gr2/overlay/trust.py +150 -0
- gr2/overlay/types.py +69 -0
- gr2/overlay/units.py +313 -0
- gr2/overlay/workspace_spec.py +59 -0
- gr2/prototypes/__init__.py +0 -0
- gr2/prototypes/cache_materialization_probe.py +190 -0
- gr2/prototypes/concurrent_event_stress.py +199 -0
- gr2/prototypes/concurrent_lease_stress.py +240 -0
- gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
- gr2/prototypes/contribution_protocol.py +665 -0
- gr2/prototypes/cross_mode_lane_stress.py +986 -0
- gr2/prototypes/jsonl_store.py +158 -0
- gr2/prototypes/lane_workspace_prototype.py +2088 -0
- gr2/prototypes/layout_model_probe.py +139 -0
- gr2/prototypes/propagation_daemon.py +546 -0
- gr2/prototypes/propagation_state_machine.py +1478 -0
- gr2/prototypes/python_exec_playground.py +194 -0
- gr2/prototypes/python_hook_runtime_playground.py +240 -0
- gr2/prototypes/python_migration_playground.py +144 -0
- gr2/prototypes/python_review_checkout_playground.py +242 -0
- gr2/prototypes/python_spec_apply_playground.py +282 -0
- gr2/prototypes/real_git_lane_materialization.py +248 -0
- gr2/prototypes/real_git_playground.py +334 -0
- gr2/prototypes/recall_lane_history.py +274 -0
- gr2/prototypes/repo_maintenance_prototype.py +659 -0
- gr2/prototypes/repo_transport_probe.py +147 -0
- gr2/python_cli/__init__.py +2 -0
- gr2/python_cli/__main__.py +6 -0
- gr2/python_cli/add.py +51 -0
- gr2/python_cli/app.py +2516 -0
- gr2/python_cli/branch.py +67 -0
- gr2/python_cli/channel_bridge.py +131 -0
- gr2/python_cli/clone_exec.py +1019 -0
- gr2/python_cli/commit.py +199 -0
- gr2/python_cli/config.py +291 -0
- gr2/python_cli/env_exec.py +419 -0
- gr2/python_cli/events.py +529 -0
- gr2/python_cli/execops.py +372 -0
- gr2/python_cli/failures.py +98 -0
- gr2/python_cli/file_exec.py +256 -0
- gr2/python_cli/gitops.py +226 -0
- gr2/python_cli/grip.py +1337 -0
- gr2/python_cli/grip_cli.py +493 -0
- gr2/python_cli/hooks.py +450 -0
- gr2/python_cli/launch_exec.py +786 -0
- gr2/python_cli/merge_verification.py +274 -0
- gr2/python_cli/migration.py +985 -0
- gr2/python_cli/open_gr_review.py +699 -0
- gr2/python_cli/platform.py +441 -0
- gr2/python_cli/pr.py +487 -0
- gr2/python_cli/project_review.py +314 -0
- gr2/python_cli/prune.py +365 -0
- gr2/python_cli/push.py +172 -0
- gr2/python_cli/review.py +462 -0
- gr2/python_cli/review_ephemeral.py +143 -0
- gr2/python_cli/review_run.py +621 -0
- gr2/python_cli/spec_apply.py +1285 -0
- gr2/python_cli/staging_cleanup.py +205 -0
- gr2/python_cli/syncops.py +920 -0
- gr2/python_cli/target.py +100 -0
- gr2/python_cli/workspace_snapshot.py +105 -0
- gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
- gr2_overlay/__init__.py +37 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""`review run <lane-dir>`: the review-owned in-lane test run — the last raw-shell
|
|
2
|
+
exit point (venv + install + pytest by hand) folded into one verb.
|
|
3
|
+
|
|
4
|
+
It runs ONLY inside an `open-gr --enter` reconstruction lane (it reads the
|
|
5
|
+
`.grip-open-gr-reconstruct.json` marker), so a green is always about a bound tree.
|
|
6
|
+
Two structural bindings make the green mean something:
|
|
7
|
+
|
|
8
|
+
* THE TREE COMPARISON — the lane's current working tree must equal the marker's
|
|
9
|
+
bound head-tree. One comparison catches both a wrong reconstruction and a lane
|
|
10
|
+
drifted after open (a touched tracked file changes the tree). Asserted BEFORE
|
|
11
|
+
the venv is created, so the venv never pollutes the hash.
|
|
12
|
+
* IMPORT UNDER THE LANE — after install, the package's resolved `__file__` must
|
|
13
|
+
live under the lane, or a second checkout shadowing it on `PYTHONPATH` would let
|
|
14
|
+
a pass be about someone else's tree (the stale editable-install trap).
|
|
15
|
+
|
|
16
|
+
Counts come from pytest's SUMMARY LINE, never the exit code (a run that collected
|
|
17
|
+
zero tests exits 0). A run that collects/selects zero tests, or whose summary line
|
|
18
|
+
cannot be parsed, is a REFUSAL, not a green.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shlex
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
# An in-repo hint read when `--install` is omitted. It lives at the REPO ROOT
|
|
33
|
+
# (the lane), NOT in a pyproject table, on purpose: a repo whose importable
|
|
34
|
+
# package is a SUBDIR (grip's is `gr2/`) has no top-level pyproject, which is the
|
|
35
|
+
# exact case the default `pip install -e <lane>` cannot handle — so a pyproject
|
|
36
|
+
# hint would be unreadable precisely where it is needed. A root sentinel file
|
|
37
|
+
# works regardless of where the package lives. Format: `key = value` lines, `#`
|
|
38
|
+
# comments; keys `install` (a command with {venv} and {lane} placeholders,
|
|
39
|
+
# shell-split FIRST, then {venv}/{lane} substituted per token — so a lane path with
|
|
40
|
+
# a space stays one token) and optional `package` (the import name whose __file__
|
|
41
|
+
# must resolve under the lane).
|
|
42
|
+
_HINT_NAME = ".review-install"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_HINT_KEYS = frozenset({"install", "package"})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _apply_install_placeholders(tokens: list[str], venv_python: Path, repo_dir: Path) -> list[str]:
|
|
49
|
+
"""Substitute `{venv}` and `{lane}` per token, so a lane path containing a space
|
|
50
|
+
stays one token (substituting before shell-splitting would let the space break the
|
|
51
|
+
token apart). The single substitution point shared by the --install flag and the
|
|
52
|
+
.review-install hint, so both accept the identical template (review-run door 2)."""
|
|
53
|
+
return [
|
|
54
|
+
tok.replace("{venv}", str(venv_python)).replace("{lane}", str(repo_dir))
|
|
55
|
+
for tok in tokens
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_install_hint(repo_dir: Path) -> dict | None:
|
|
60
|
+
"""Parse `<repo_dir>/.review-install`; return {'install': str, 'package': str}
|
|
61
|
+
(both optional keys) or None when the file is absent. An unrecognised key is a
|
|
62
|
+
REFUSAL (`bad_hint`), not a silent skip: a typo like `instal = ...` would
|
|
63
|
+
otherwise fall through to the default install and refuse under a cause the repo
|
|
64
|
+
never declared."""
|
|
65
|
+
p = repo_dir / _HINT_NAME
|
|
66
|
+
if not p.is_file():
|
|
67
|
+
return None
|
|
68
|
+
out: dict[str, str] = {}
|
|
69
|
+
for raw in p.read_text().splitlines():
|
|
70
|
+
line = raw.strip()
|
|
71
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
72
|
+
continue
|
|
73
|
+
key, _, val = line.partition("=")
|
|
74
|
+
key = key.strip()
|
|
75
|
+
if key not in _HINT_KEYS:
|
|
76
|
+
raise ReviewRunRefused(
|
|
77
|
+
"bad_hint",
|
|
78
|
+
f"unrecognised key {key!r} in {p}; allowed keys are "
|
|
79
|
+
f"{sorted(_HINT_KEYS)}",
|
|
80
|
+
)
|
|
81
|
+
out[key] = val.strip()
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
_MARKER_NAME = ".grip-open-gr-reconstruct.json"
|
|
85
|
+
_RECEIPT_NAME = ".grip-review-run.json"
|
|
86
|
+
# The full pytest output, persisted beside the receipt. The receipt's counts and
|
|
87
|
+
# `failed_ids` say WHAT failed; this file is the raw text a reviewer reads to see
|
|
88
|
+
# WHY. Named so `close-gr` can carry it (and the receipt) out before it reclaims
|
|
89
|
+
# the lane (review-run door 1).
|
|
90
|
+
_OUTPUT_LOG_NAME = ".grip-review-run.log"
|
|
91
|
+
_VENV_DIRNAME = ".venv"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ReviewRunRefused(Exception):
|
|
95
|
+
"""A structural refusal: the run cannot yield a trustworthy green."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
98
|
+
self.code = code
|
|
99
|
+
self.detail = detail
|
|
100
|
+
super().__init__(f"{code}: {detail}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _git(repo_dir: Path, *args: str, env: dict | None = None) -> str:
|
|
104
|
+
proc = subprocess.run(
|
|
105
|
+
["git", "-C", str(repo_dir), *args],
|
|
106
|
+
text=True,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
env=env,
|
|
109
|
+
)
|
|
110
|
+
if proc.returncode != 0:
|
|
111
|
+
raise ReviewRunRefused(
|
|
112
|
+
"git_failed",
|
|
113
|
+
f"git {' '.join(args)} in {repo_dir} exited {proc.returncode}: "
|
|
114
|
+
f"{proc.stderr.strip()}",
|
|
115
|
+
)
|
|
116
|
+
return proc.stdout.strip()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---- the tree comparison (drift + reconstruction, ONE check) ----------------
|
|
120
|
+
|
|
121
|
+
def compute_working_tree(repo_dir: Path) -> str:
|
|
122
|
+
"""The tree hash of the current TRACKED content of repo_dir, computed in a
|
|
123
|
+
throwaway index so the real index is untouched. `add -u` stages modifications
|
|
124
|
+
and deletions of tracked files but NOT untracked additions, so the open-gr
|
|
125
|
+
marker, the lane `.venv`, and the run receipt do not read as drift — only a
|
|
126
|
+
change to a reconstructed (tracked) file does."""
|
|
127
|
+
with tempfile.TemporaryDirectory() as td:
|
|
128
|
+
idx = str(Path(td) / "index")
|
|
129
|
+
env = {**os.environ, "GIT_INDEX_FILE": idx}
|
|
130
|
+
_git(repo_dir, "read-tree", "HEAD", env=env)
|
|
131
|
+
_git(repo_dir, "add", "-u", env=env)
|
|
132
|
+
return _git(repo_dir, "write-tree", env=env)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def assert_lane_tree_bound(repo_dir: Path, bound_head_tree: str) -> str:
|
|
136
|
+
"""THE tracked-tree comparison. Refuse unless the lane's current tracked tree
|
|
137
|
+
equals the bound head-tree recorded at open. Returns the computed tree. Dropping
|
|
138
|
+
this comparison lets a MODIFIED tracked file pass as a green."""
|
|
139
|
+
if not bound_head_tree:
|
|
140
|
+
raise ReviewRunRefused(
|
|
141
|
+
"no_bound_tree",
|
|
142
|
+
f"the open-gr marker for {repo_dir} records no bound_head_tree; "
|
|
143
|
+
"reopen the lane with a build that records it",
|
|
144
|
+
)
|
|
145
|
+
current = compute_working_tree(repo_dir)
|
|
146
|
+
if current != bound_head_tree:
|
|
147
|
+
raise ReviewRunRefused(
|
|
148
|
+
"tree_drift",
|
|
149
|
+
f"lane tree {current} != bound head-tree {bound_head_tree} "
|
|
150
|
+
f"({repo_dir}); the run would not be about the pinned head",
|
|
151
|
+
)
|
|
152
|
+
return current
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Untracked paths the run itself is expected to create; everything else untracked in
|
|
156
|
+
# the lane is drift, because an injected conftest.py or module can change what the
|
|
157
|
+
# tests do WITHOUT touching the tracked tree (which `assert_lane_tree_bound` sees).
|
|
158
|
+
_UNTRACKED_ALLOW_NAMES = frozenset({_MARKER_NAME, _RECEIPT_NAME})
|
|
159
|
+
_UNTRACKED_ALLOW_TOP = (_VENV_DIRNAME + "/",)
|
|
160
|
+
_UNTRACKED_ALLOW_SEGMENTS = frozenset({"__pycache__", ".pytest_cache", ".mypy_cache"})
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_allowlisted_untracked(rel_path: str) -> bool:
|
|
164
|
+
if rel_path in _UNTRACKED_ALLOW_NAMES:
|
|
165
|
+
return True
|
|
166
|
+
if any(rel_path == pre.rstrip("/") or rel_path.startswith(pre) for pre in _UNTRACKED_ALLOW_TOP):
|
|
167
|
+
return True
|
|
168
|
+
segments = rel_path.strip("/").split("/")
|
|
169
|
+
if any(seg in _UNTRACKED_ALLOW_SEGMENTS or seg.endswith(".egg-info") for seg in segments):
|
|
170
|
+
return True
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def assert_no_untracked_drift(repo_dir: Path) -> None:
|
|
175
|
+
"""Refuse if the lane holds any untracked path the run did not create. Without
|
|
176
|
+
this an untracked `conftest.py` (or shadow module) that patches the package turns
|
|
177
|
+
a failing tree green while the tracked-tree comparison passes. The complement of
|
|
178
|
+
`assert_lane_tree_bound`: dropping either reds only its own drift witness."""
|
|
179
|
+
out = _git(repo_dir, "status", "--porcelain")
|
|
180
|
+
offending = []
|
|
181
|
+
for line in out.splitlines():
|
|
182
|
+
if line.startswith("?? "):
|
|
183
|
+
rel = line[3:].strip().strip('"')
|
|
184
|
+
if not _is_allowlisted_untracked(rel):
|
|
185
|
+
offending.append(rel)
|
|
186
|
+
if offending:
|
|
187
|
+
raise ReviewRunRefused(
|
|
188
|
+
"untracked_drift",
|
|
189
|
+
f"untracked path(s) in the lane the run did not create: "
|
|
190
|
+
f"{', '.join(offending[:5])}; an injected conftest/module can change test "
|
|
191
|
+
"behavior without touching the tracked tree",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---- import resolves under the lane -----------------------------------------
|
|
196
|
+
|
|
197
|
+
def resolve_import_file(venv_python: Path, package: str, env: dict) -> str:
|
|
198
|
+
"""Import `package` in the venv python (under `env`) and return its __file__."""
|
|
199
|
+
proc = subprocess.run(
|
|
200
|
+
[str(venv_python), "-c", f"import {package} as _m; print(_m.__file__ or '')"],
|
|
201
|
+
text=True,
|
|
202
|
+
capture_output=True,
|
|
203
|
+
env=env,
|
|
204
|
+
)
|
|
205
|
+
if proc.returncode != 0:
|
|
206
|
+
raise ReviewRunRefused(
|
|
207
|
+
"import_failed",
|
|
208
|
+
f"could not import {package!r} in the lane venv: {proc.stderr.strip()}",
|
|
209
|
+
)
|
|
210
|
+
path = proc.stdout.strip()
|
|
211
|
+
if not path:
|
|
212
|
+
raise ReviewRunRefused(
|
|
213
|
+
"import_no_file",
|
|
214
|
+
f"{package!r} has no __file__ (namespace package?); cannot bind the "
|
|
215
|
+
"install to the lane",
|
|
216
|
+
)
|
|
217
|
+
return path
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def assert_import_under_lane(resolved_file: str, lane_dir: Path) -> None:
|
|
221
|
+
"""Refuse unless the resolved import path lives under the lane. A second checkout
|
|
222
|
+
on PYTHONPATH would otherwise let a pass be about a different tree."""
|
|
223
|
+
p = Path(resolved_file).resolve()
|
|
224
|
+
root = lane_dir.resolve()
|
|
225
|
+
if root != p and root not in p.parents:
|
|
226
|
+
raise ReviewRunRefused(
|
|
227
|
+
"import_escapes_lane",
|
|
228
|
+
f"{p} does not resolve under the lane {root}; a checkout outside the "
|
|
229
|
+
"lane is shadowing the reconstruction (stale editable install)",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---- undeclared-extra detection (pip exits 0 but warns) ----
|
|
234
|
+
|
|
235
|
+
# pip exits 0 when an install requests an extra the package does not declare,
|
|
236
|
+
# emitting `WARNING: <name> <version> does not provide the extra 'X'` on stderr
|
|
237
|
+
# (older pip omits the version). The invariant is the phrase, so anchor on it and
|
|
238
|
+
# ignore the version. Match either quote style pip might use.
|
|
239
|
+
_UNDECLARED_EXTRA_RE = re.compile(r"""does not provide the extra ['"]([^'"]+)['"]""")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def detect_undeclared_extras(output: str) -> list[str]:
|
|
243
|
+
"""Return the sorted unique extra names pip reported as undeclared in `output`.
|
|
244
|
+
|
|
245
|
+
A typo'd or undeclared extra in a repo's own `.review-install` (say `gr2[devv]`
|
|
246
|
+
for `gr2[dev]`) makes pip install NOTHING of what that extra promised — pytest
|
|
247
|
+
and the rest of the test deps — while exiting 0. The run then fails later under
|
|
248
|
+
`pytest_not_installed`, which points at the symptom, not the bad extra name. This
|
|
249
|
+
lets the run name the root cause first."""
|
|
250
|
+
return sorted({m.group(1) for m in _UNDECLARED_EXTRA_RE.finditer(output)})
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---- pytest summary parsing (counts from the summary line, not exit code) ----
|
|
254
|
+
|
|
255
|
+
_COLLECTED_RE = re.compile(r"collected (\d+) item")
|
|
256
|
+
_SELECTED_RE = re.compile(r"(\d+) selected")
|
|
257
|
+
# A summary line ends with "in <time>s" (barred in normal mode, bare in -q), or is
|
|
258
|
+
# the "no tests ran in <time>s" line; leading/trailing "=" bars are optional.
|
|
259
|
+
_SUMMARY_LINE_RE = re.compile(r"(?:in \d+\.\d+s|no tests ran)")
|
|
260
|
+
_TIME_TAIL_RE = re.compile(r"\bin \d+\.\d+s\b|\bno tests ran\b")
|
|
261
|
+
_COUNT_RE = re.compile(
|
|
262
|
+
r"(\d+) (passed|failed|error|errors|skipped|xfailed|xpassed|deselected|warning|warnings)"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def parse_pytest_summary(stdout: str) -> dict | None:
|
|
267
|
+
"""Counts from pytest's SUMMARY LINE, never the exit code. Handles both the
|
|
268
|
+
barred normal-mode line (`===== 3 passed in 0.01s =====`) and the bare `-q` line
|
|
269
|
+
(`1 passed in 0.00s`, `1 deselected in 0.00s`). Returns a dict with
|
|
270
|
+
collected/selected/deselected/passed/failed/skipped/xfailed/errors, or None if
|
|
271
|
+
no summary line exists (unparseable output -> the caller refuses). A run where no
|
|
272
|
+
test ran yields selected==0 (the caller refuses)."""
|
|
273
|
+
lines = stdout.splitlines()
|
|
274
|
+
summary_body: str | None = None
|
|
275
|
+
for line in reversed(lines):
|
|
276
|
+
if _SUMMARY_LINE_RE.search(line):
|
|
277
|
+
summary_body = line.strip().strip("=").strip()
|
|
278
|
+
break
|
|
279
|
+
if summary_body is None:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
counts = {k: 0 for k in ("passed", "failed", "errors", "skipped", "xfailed", "xpassed")}
|
|
283
|
+
deselected = 0
|
|
284
|
+
for n, word in _COUNT_RE.findall(summary_body):
|
|
285
|
+
if word in ("error", "errors"):
|
|
286
|
+
counts["errors"] = int(n)
|
|
287
|
+
elif word in ("warning", "warnings"):
|
|
288
|
+
continue
|
|
289
|
+
elif word == "deselected":
|
|
290
|
+
deselected = int(n)
|
|
291
|
+
else:
|
|
292
|
+
counts[word] = int(n)
|
|
293
|
+
|
|
294
|
+
collected = None
|
|
295
|
+
selected = None
|
|
296
|
+
for line in lines:
|
|
297
|
+
cm = _COLLECTED_RE.search(line)
|
|
298
|
+
if cm:
|
|
299
|
+
collected = int(cm.group(1))
|
|
300
|
+
sm = _SELECTED_RE.search(line)
|
|
301
|
+
if sm:
|
|
302
|
+
selected = int(sm.group(1))
|
|
303
|
+
dm = re.search(r"(\d+) deselected", line)
|
|
304
|
+
if dm:
|
|
305
|
+
deselected = max(deselected, int(dm.group(1)))
|
|
306
|
+
|
|
307
|
+
ran = (
|
|
308
|
+
counts["passed"] + counts["failed"] + counts["errors"]
|
|
309
|
+
+ counts["skipped"] + counts["xfailed"] + counts["xpassed"]
|
|
310
|
+
)
|
|
311
|
+
if "no tests ran" in summary_body:
|
|
312
|
+
selected = 0
|
|
313
|
+
elif selected is None:
|
|
314
|
+
selected = ran
|
|
315
|
+
return {
|
|
316
|
+
"collected": collected,
|
|
317
|
+
"deselected": deselected,
|
|
318
|
+
"selected": selected,
|
|
319
|
+
**counts,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ---- failed-test node ids (which tests failed, not just how many) -----------
|
|
324
|
+
|
|
325
|
+
# pytest's short test summary info section (emitted under `-rfE`, which the run
|
|
326
|
+
# always passes) lists one line per non-passing outcome:
|
|
327
|
+
# FAILED tests/test_x.py::test_bad - AssertionError: ...
|
|
328
|
+
# FAILED tests/test_x.py::test_p[case 2 with spaces] - AssertionError
|
|
329
|
+
# ERROR tests/test_x.py::test_y - fixture 'conn' not found (setup/collection)
|
|
330
|
+
# The node id is everything between the status word and pytest's ` - <message>`
|
|
331
|
+
# separator (space-dash-space), or the rest of the line when there is no message.
|
|
332
|
+
# A `\S+` token would truncate a PARAMETRIZED id at the first space inside its
|
|
333
|
+
# brackets, losing the exact case a reviewer must re-run — so match to the ` - `
|
|
334
|
+
# instead. Anchored at line start (MULTILINE) so the `ERRORS` banner and the
|
|
335
|
+
# `___ ERROR at setup of ___` divider lines, which do not start with the word, are
|
|
336
|
+
# not mistaken for summary rows. review-run door 1: the 35 env failures in the
|
|
337
|
+
# real review were unrecoverable from the receipt because this was never captured.
|
|
338
|
+
# Known edge: a param whose brackets literally contain " - " (space-dash-space)
|
|
339
|
+
# truncates there, since that is also the id/message separator; pytest usually
|
|
340
|
+
# sanitizes such ids and the truncation still keeps the file and test stem, so it
|
|
341
|
+
# is accepted rather than guarded.
|
|
342
|
+
_FAILED_ID_RE = re.compile(r"^(?:FAILED|ERROR)\s+(.+?)(?: - .*)?$", re.MULTILINE)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def parse_failed_ids(output: str) -> list[str]:
|
|
346
|
+
"""Return the sorted unique node ids pytest reported as FAILED or ERROR in
|
|
347
|
+
`output`'s short test summary. A count of failures with no ids is a dead end
|
|
348
|
+
for a reviewer; this is the path back to the exact tests to re-run."""
|
|
349
|
+
return sorted({m.group(1) for m in _FAILED_ID_RE.finditer(output)})
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def merge_report_flags(pytest_args: list[str]) -> list[str]:
|
|
353
|
+
"""Return `pytest_args` with a single `-r` spec GUARANTEED to make the short test
|
|
354
|
+
summary list every FAILED and ERROR node id for parse_failed_ids.
|
|
355
|
+
|
|
356
|
+
Two pytest facts drive this and neither is `-r`-is-additive:
|
|
357
|
+
* `-r` is LAST-WINS across tokens, so a prepended `-rfE` is silently overridden
|
|
358
|
+
by any later caller `-r` — failed_ids then comes back EMPTY on a real red run.
|
|
359
|
+
* within one `-r` spec, the chars are processed IN ORDER and `N` (none) CLEARS
|
|
360
|
+
everything before it. So a sorted union like `-rENf` loses ERROR: E is added,
|
|
361
|
+
N clears it, f is added — a red run with an ERROR-at-setup keeps FAILED and
|
|
362
|
+
drops the ERROR ids. (Measured: `-rfE` prints both, `-rENf` FAILED only.)
|
|
363
|
+
|
|
364
|
+
So: collect the caller's `-r` chars in ORDER (deduped), DROP `N` (the run requires
|
|
365
|
+
output, so "none" cannot stand), drop any caller f/E, then append `f` and `E` LAST
|
|
366
|
+
so nothing that follows can clear them. `a`/`A` (all / all-but-passed) stay, ahead
|
|
367
|
+
of f/E, and are harmless supersets."""
|
|
368
|
+
required = ("f", "E")
|
|
369
|
+
caller_seq: list[str] = []
|
|
370
|
+
seen: set[str] = set()
|
|
371
|
+
rest: list[str] = []
|
|
372
|
+
i = 0
|
|
373
|
+
while i < len(pytest_args):
|
|
374
|
+
a = pytest_args[i]
|
|
375
|
+
chars: str | None = None
|
|
376
|
+
if a == "-r" and i + 1 < len(pytest_args): # `-r fE` (separate arg)
|
|
377
|
+
chars = pytest_args[i + 1]
|
|
378
|
+
i += 2
|
|
379
|
+
elif a.startswith("-r") and len(a) > 2: # `-rfE` (attached)
|
|
380
|
+
chars = a[2:]
|
|
381
|
+
i += 1
|
|
382
|
+
else:
|
|
383
|
+
rest.append(a)
|
|
384
|
+
i += 1
|
|
385
|
+
continue
|
|
386
|
+
for c in chars:
|
|
387
|
+
if c not in seen:
|
|
388
|
+
seen.add(c)
|
|
389
|
+
caller_seq.append(c)
|
|
390
|
+
ordered = [c for c in caller_seq if c not in ("N", *required)]
|
|
391
|
+
ordered.extend(required)
|
|
392
|
+
return ["-r" + "".join(ordered), *rest]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---- the verb ---------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
def _read_marker(lane_dir: Path) -> dict:
|
|
398
|
+
marker_path = lane_dir / _MARKER_NAME
|
|
399
|
+
if not marker_path.exists():
|
|
400
|
+
raise ReviewRunRefused(
|
|
401
|
+
"no_marker",
|
|
402
|
+
f"no open-gr marker at {marker_path}; `review run` only runs inside a "
|
|
403
|
+
"lane opened by `review open-gr --enter`",
|
|
404
|
+
)
|
|
405
|
+
marker = json.loads(marker_path.read_text())
|
|
406
|
+
if marker.get("kind") != "open-gr-reconstruct":
|
|
407
|
+
raise ReviewRunRefused(
|
|
408
|
+
"not_open_gr", f"{marker_path} is not an open-gr reconstruction marker"
|
|
409
|
+
)
|
|
410
|
+
return marker
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def run_review_lane(
|
|
414
|
+
lane_dir: Path,
|
|
415
|
+
*,
|
|
416
|
+
package: str | None = None,
|
|
417
|
+
pytest_args: list[str],
|
|
418
|
+
python: str | None = None,
|
|
419
|
+
install: list[str] | None = None,
|
|
420
|
+
system_site_packages: bool = False,
|
|
421
|
+
) -> dict:
|
|
422
|
+
"""Create `<lane>/.venv`, install the reconstructed tree, and run pytest — but
|
|
423
|
+
only after the lane's tree is proven to equal the bound head-tree and the import
|
|
424
|
+
is proven to resolve under the lane. Returns a receipt. Raises ReviewRunRefused
|
|
425
|
+
for any structural problem (no marker, tree drift, import escape, zero collected,
|
|
426
|
+
unparseable summary)."""
|
|
427
|
+
lane_dir = Path(lane_dir).resolve()
|
|
428
|
+
marker = _read_marker(lane_dir)
|
|
429
|
+
repos = marker.get("repos", [])
|
|
430
|
+
if len(repos) != 1:
|
|
431
|
+
raise ReviewRunRefused(
|
|
432
|
+
"multi_repo_lane",
|
|
433
|
+
f"v1 review run handles a single-repo lane; marker binds {len(repos)} "
|
|
434
|
+
"repos (multi-repo is a follow-on)",
|
|
435
|
+
)
|
|
436
|
+
repo = repos[0]
|
|
437
|
+
bound_tree = repo.get("bound_head_tree", "")
|
|
438
|
+
repo_dir = lane_dir # single-repo lane: the clone IS the lane
|
|
439
|
+
|
|
440
|
+
# (1) THE TREE COMPARISON — before the venv exists, so it never pollutes the hash.
|
|
441
|
+
# Two halves: tracked content equals the bound tree, AND no untracked path the
|
|
442
|
+
# run did not create (an injected conftest changes behavior invisibly to the
|
|
443
|
+
# tracked-tree hash).
|
|
444
|
+
assert_lane_tree_bound(repo_dir, bound_tree)
|
|
445
|
+
assert_no_untracked_drift(repo_dir)
|
|
446
|
+
|
|
447
|
+
# (2) venv in the lane, so close-gr reclaims it.
|
|
448
|
+
interpreter = python or sys.executable
|
|
449
|
+
venv_dir = lane_dir / _VENV_DIRNAME
|
|
450
|
+
venv_cmd = [interpreter, "-m", "venv"]
|
|
451
|
+
if system_site_packages:
|
|
452
|
+
venv_cmd.append("--system-site-packages")
|
|
453
|
+
venv_cmd.append(str(venv_dir))
|
|
454
|
+
proc = subprocess.run(venv_cmd, text=True, capture_output=True)
|
|
455
|
+
if proc.returncode != 0:
|
|
456
|
+
raise ReviewRunRefused("venv_failed", f"venv create failed: {proc.stderr.strip()}")
|
|
457
|
+
venv_python = venv_dir / "bin" / "python"
|
|
458
|
+
|
|
459
|
+
# (3) resolve install + package, tracking WHERE each came from. An explicit
|
|
460
|
+
# --install/--package always wins; otherwise the repo's own .review-install
|
|
461
|
+
# hint supplies them, so a repo that declares itself (like grip, whose package
|
|
462
|
+
# is the gr2/ subdir) needs no hand-written flags. The hint's install template
|
|
463
|
+
# is SHELL-SPLIT FIRST, then {venv}/{lane} substituted per token, so a lane
|
|
464
|
+
# path containing a space stays one token even with an unquoted hint line
|
|
465
|
+
# (substituting before splitting would let the space break the token apart).
|
|
466
|
+
install_source = "flag" if install is not None else None
|
|
467
|
+
package_source = "flag" if package is not None else None
|
|
468
|
+
hint = read_install_hint(repo_dir)
|
|
469
|
+
if install is not None:
|
|
470
|
+
# The --install FLAG supports the SAME {venv}/{lane} placeholders as the hint,
|
|
471
|
+
# so the documented template works identically whether typed on the CLI or
|
|
472
|
+
# declared in .review-install. review-run door 2: only the hint substituted, so
|
|
473
|
+
# a reviewer who passed the documented `{venv} -m pip install -e {lane}` on the
|
|
474
|
+
# flag got literal braces and a failed install.
|
|
475
|
+
install = _apply_install_placeholders(install, venv_python, repo_dir)
|
|
476
|
+
elif hint and hint.get("install"):
|
|
477
|
+
install = _apply_install_placeholders(shlex.split(hint["install"]), venv_python, repo_dir)
|
|
478
|
+
install_source = "hint"
|
|
479
|
+
if package is None and hint and hint.get("package"):
|
|
480
|
+
package = hint["package"]
|
|
481
|
+
package_source = "hint"
|
|
482
|
+
if package is None:
|
|
483
|
+
raise ReviewRunRefused(
|
|
484
|
+
"no_package",
|
|
485
|
+
"no --package given and the lane's .review-install declares none; a "
|
|
486
|
+
"package name is required so the install can be proven to resolve under "
|
|
487
|
+
"the lane",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# (4) install the reconstructed tree editable. A hint (or flag) can name a binary
|
|
491
|
+
# that does not exist; subprocess.run then raises OSError, which must become a
|
|
492
|
+
# refusal, never an uncaught traceback (tree content chooses the command, so a
|
|
493
|
+
# typo in a repo's own hint must not produce the one shape review run promises
|
|
494
|
+
# never to give).
|
|
495
|
+
if install is not None:
|
|
496
|
+
install_cmd = install
|
|
497
|
+
else:
|
|
498
|
+
install_cmd = [str(venv_python), "-m", "pip", "install", "-e", str(repo_dir)]
|
|
499
|
+
install_source = "default"
|
|
500
|
+
try:
|
|
501
|
+
proc = subprocess.run(install_cmd, text=True, capture_output=True, cwd=str(repo_dir))
|
|
502
|
+
except OSError as exc:
|
|
503
|
+
raise ReviewRunRefused(
|
|
504
|
+
"install_failed",
|
|
505
|
+
f"install `{' '.join(install_cmd)}` could not run: {exc}",
|
|
506
|
+
)
|
|
507
|
+
if proc.returncode != 0:
|
|
508
|
+
raise ReviewRunRefused(
|
|
509
|
+
"install_failed",
|
|
510
|
+
f"install `{' '.join(install_cmd)}` failed: {proc.stderr.strip()[-800:]}",
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# (4a) An undeclared extra does NOT fail the install — pip warns and exits 0,
|
|
514
|
+
# installing none of that extra's dependencies. Named here, BEFORE the import
|
|
515
|
+
# and pytest checks, so a typo'd extra surfaces as its own root cause instead
|
|
516
|
+
# of the misleading `pytest_not_installed` symptom it would otherwise produce.
|
|
517
|
+
undeclared = detect_undeclared_extras(proc.stdout + "\n" + proc.stderr)
|
|
518
|
+
if undeclared:
|
|
519
|
+
raise ReviewRunRefused(
|
|
520
|
+
"undeclared_extra",
|
|
521
|
+
"the install requested extra(s) the package does not declare: "
|
|
522
|
+
f"{', '.join(undeclared)}. pip exits 0 on an undeclared extra and installs "
|
|
523
|
+
"nothing for it, so the test dependencies it was meant to bring (pytest and "
|
|
524
|
+
"the rest) are silently absent. Fix the extra name in --install or the "
|
|
525
|
+
f"repo's .review-install. install: `{' '.join(install_cmd)}`",
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
# (5) IMPORT UNDER THE LANE — in the same env pytest will use.
|
|
529
|
+
run_env = {**os.environ}
|
|
530
|
+
resolved_file = resolve_import_file(venv_python, package, run_env)
|
|
531
|
+
assert_import_under_lane(resolved_file, lane_dir)
|
|
532
|
+
|
|
533
|
+
# (6) pytest must be importable in the lane venv. A plain editable install does
|
|
534
|
+
# not bring it (pytest is a test-time extra), and running pytest anyway
|
|
535
|
+
# yields exit 1 with no summary — which the summary check would mislabel
|
|
536
|
+
# `unparseable_summary`. Name the real cause instead, and keep it a refusal.
|
|
537
|
+
proc = subprocess.run(
|
|
538
|
+
[str(venv_python), "-c", "import pytest"], text=True, capture_output=True, env=run_env
|
|
539
|
+
)
|
|
540
|
+
if proc.returncode != 0:
|
|
541
|
+
raise ReviewRunRefused(
|
|
542
|
+
"pytest_not_installed",
|
|
543
|
+
"pytest is not importable in the lane venv; a plain editable install does "
|
|
544
|
+
"not bring it. Add pytest to --install or the repo's .review-install "
|
|
545
|
+
f"(it is a test-time dependency). stderr: {proc.stderr.strip()[-300:]}",
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# (7) run pytest; counts from the summary line, never the exit code. The full
|
|
549
|
+
# output is persisted to a log in the lane and the failed node ids are parsed
|
|
550
|
+
# from it, so a red receipt says WHICH tests failed and the raw text survives
|
|
551
|
+
# for close-gr to carry out (review-run door 1).
|
|
552
|
+
# Guarantee the short test summary lists every FAILED/ERROR node id for
|
|
553
|
+
# parse_failed_ids. pytest emits those summary lines only under `-r`, and `-r` is
|
|
554
|
+
# LAST-WINS: a prepended `-rfE` would be silently overridden by a caller `-rN`/`-rs`,
|
|
555
|
+
# leaving failed_ids empty on a real red run. merge_report_flags folds f/E INTO the
|
|
556
|
+
# caller's own -r chars, so f and E survive whatever the caller passed.
|
|
557
|
+
test_cmd = [str(venv_python), "-m", "pytest", *merge_report_flags(pytest_args)]
|
|
558
|
+
proc = subprocess.run(
|
|
559
|
+
test_cmd, text=True, capture_output=True, cwd=str(repo_dir), env=run_env
|
|
560
|
+
)
|
|
561
|
+
pytest_output = proc.stdout + "\n" + proc.stderr
|
|
562
|
+
(lane_dir / _OUTPUT_LOG_NAME).write_text(pytest_output)
|
|
563
|
+
failed_ids = parse_failed_ids(pytest_output)
|
|
564
|
+
summary = parse_pytest_summary(pytest_output)
|
|
565
|
+
if summary is None:
|
|
566
|
+
raise ReviewRunRefused(
|
|
567
|
+
"unparseable_summary",
|
|
568
|
+
"no pytest summary line found; refusing to call this a green "
|
|
569
|
+
f"(pytest exit was {proc.returncode})",
|
|
570
|
+
)
|
|
571
|
+
if not summary.get("selected"):
|
|
572
|
+
raise ReviewRunRefused(
|
|
573
|
+
"zero_collected",
|
|
574
|
+
f"pytest selected 0 tests (collected={summary.get('collected')}, "
|
|
575
|
+
f"deselected={summary.get('deselected')}); a zero-test run is not a green",
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
version = subprocess.run(
|
|
579
|
+
[str(venv_python), "--version"], text=True, capture_output=True
|
|
580
|
+
).stdout.strip() or subprocess.run(
|
|
581
|
+
[str(venv_python), "-V"], text=True, capture_output=True
|
|
582
|
+
).stderr.strip()
|
|
583
|
+
|
|
584
|
+
# A green requires at least one PASS: an all-skipped or all-deselected run has no
|
|
585
|
+
# failure but proves nothing, so it is not a green.
|
|
586
|
+
result = (
|
|
587
|
+
"green"
|
|
588
|
+
if (summary["passed"] >= 1 and summary["failed"] == 0 and summary["errors"] == 0)
|
|
589
|
+
else "red"
|
|
590
|
+
)
|
|
591
|
+
receipt = {
|
|
592
|
+
"kind": "review-run",
|
|
593
|
+
# When this run happened, so close-gr can key the preserved evidence by
|
|
594
|
+
# (gr commit, run time) and two closes of the same lane name do not overwrite
|
|
595
|
+
# each other's receipt/log.
|
|
596
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
597
|
+
"gr_commit": marker.get("gr_commit", ""),
|
|
598
|
+
"bound_head": repo.get("bound_head", ""),
|
|
599
|
+
"bound_head_tree": bound_tree,
|
|
600
|
+
"interpreter": {"path": str(venv_python), "version": version},
|
|
601
|
+
"resolved_install_path": resolved_file,
|
|
602
|
+
"install_command": install_cmd,
|
|
603
|
+
"install_source": install_source,
|
|
604
|
+
"package_source": package_source,
|
|
605
|
+
"test_command": test_cmd,
|
|
606
|
+
"collected": summary["collected"],
|
|
607
|
+
"deselected": summary["deselected"],
|
|
608
|
+
"selected": summary["selected"],
|
|
609
|
+
"passed": summary["passed"],
|
|
610
|
+
"failed": summary["failed"],
|
|
611
|
+
"skipped": summary["skipped"],
|
|
612
|
+
"xfailed": summary["xfailed"],
|
|
613
|
+
"errors": summary["errors"],
|
|
614
|
+
# WHICH tests failed (node ids parsed from the summary), so a red receipt is
|
|
615
|
+
# actionable and not just a count, and the raw output log this run wrote.
|
|
616
|
+
"failed_ids": failed_ids,
|
|
617
|
+
"output_log": _OUTPUT_LOG_NAME,
|
|
618
|
+
"result": result,
|
|
619
|
+
}
|
|
620
|
+
(lane_dir / _RECEIPT_NAME).write_text(json.dumps(receipt, indent=2) + "\n")
|
|
621
|
+
return receipt
|