sf-m3-cli 0.2.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.
- m3_cli/__init__.py +5 -0
- m3_cli/__main__.py +5 -0
- m3_cli/branding.py +10 -0
- m3_cli/control_plane.py +278 -0
- m3_cli/doctor.py +376 -0
- m3_cli/errors.py +10 -0
- m3_cli/init.py +236 -0
- m3_cli/main.py +271 -0
- m3_cli/runtime.py +123 -0
- m3_cli/setup.py +353 -0
- m3_cli/supervisor.py +1336 -0
- m3_cli/ui/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- m3_cli/ui/assets/index-BB1JIVWj.css +1 -0
- m3_cli/ui/assets/index-DRJt176-.js +95 -0
- m3_cli/ui/assets/inter-latin-standard-normal-BwkfbSeq.woff2 +0 -0
- m3_cli/ui/index.html +34 -0
- m3_cli/web.py +166 -0
- sf_m3_cli-0.2.0.dist-info/METADATA +395 -0
- sf_m3_cli-0.2.0.dist-info/RECORD +22 -0
- sf_m3_cli-0.2.0.dist-info/WHEEL +4 -0
- sf_m3_cli-0.2.0.dist-info/entry_points.txt +2 -0
- sf_m3_cli-0.2.0.dist-info/licenses/LICENSE +197 -0
m3_cli/supervisor.py
ADDED
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
"""Run pytest with the Python environment belonging to the project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import typing
|
|
20
|
+
from collections import deque
|
|
21
|
+
from collections.abc import Mapping, Sequence
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
from urllib.parse import quote
|
|
28
|
+
|
|
29
|
+
from .branding import M3_ASCII_ART
|
|
30
|
+
|
|
31
|
+
if typing.TYPE_CHECKING:
|
|
32
|
+
import tomli as _tomllib
|
|
33
|
+
elif sys.version_info >= (3, 11):
|
|
34
|
+
import tomllib as _tomllib
|
|
35
|
+
else: # pragma: no cover
|
|
36
|
+
import tomli as _tomllib
|
|
37
|
+
|
|
38
|
+
OPERATIONAL_ERROR = 2
|
|
39
|
+
_HARNESS_KINDS = {"claude", "claude_code", "opencode", "codex", "pi", "acp"}
|
|
40
|
+
_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
41
|
+
_RUNTIME_VERSION = re.compile(
|
|
42
|
+
r"^(?:latest|(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9a-z]+(?:\.[0-9a-z]+)*)?)$"
|
|
43
|
+
)
|
|
44
|
+
_PROGRESS_LINE_LIMIT = 2048
|
|
45
|
+
_PROGRESS_TOTAL_LIMIT = 16 * 1024 * 1024
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _format_progress_event(event: Mapping[str, Any]) -> str | None:
|
|
49
|
+
"""Render runtime-core acquisition records as compact human progress."""
|
|
50
|
+
|
|
51
|
+
def safe_text(value: Any, maximum: int) -> str | None:
|
|
52
|
+
if not isinstance(value, str) or len(value) > maximum:
|
|
53
|
+
return None
|
|
54
|
+
return (
|
|
55
|
+
value if all(32 <= ord(character) <= 126 for character in value) else None
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
event_name = event.get("event")
|
|
59
|
+
if not isinstance(event_name, str):
|
|
60
|
+
value = event.get("message", event.get("status"))
|
|
61
|
+
return safe_text(value, 256)
|
|
62
|
+
if event_name not in {
|
|
63
|
+
"acquire_start",
|
|
64
|
+
"download_progress",
|
|
65
|
+
"acquire_complete",
|
|
66
|
+
"cache_hit",
|
|
67
|
+
}:
|
|
68
|
+
return None
|
|
69
|
+
kind = safe_text(event.get("kind"), 64)
|
|
70
|
+
version = safe_text(event.get("resolved_version"), 64)
|
|
71
|
+
prefix = ""
|
|
72
|
+
if kind is not None:
|
|
73
|
+
prefix = {"opencode": "OpenCode", "claude_code": "Claude Code"}.get(
|
|
74
|
+
kind, kind.title()
|
|
75
|
+
)
|
|
76
|
+
if version is not None:
|
|
77
|
+
prefix += f" {version}"
|
|
78
|
+
prefix += ": "
|
|
79
|
+
phase_value = event.get("phase")
|
|
80
|
+
phase = phase_value if isinstance(phase_value, str) else ""
|
|
81
|
+
action = {
|
|
82
|
+
"resolve": "resolving release",
|
|
83
|
+
"download": "downloading",
|
|
84
|
+
"extract": "installing",
|
|
85
|
+
"verify": "verifying",
|
|
86
|
+
"ready": "loaded from cache"
|
|
87
|
+
if event_name == "cache_hit"
|
|
88
|
+
else "ready; running test",
|
|
89
|
+
}.get(phase, event_name)
|
|
90
|
+
detail = ""
|
|
91
|
+
current, total = event.get("bytes"), event.get("total_bytes")
|
|
92
|
+
if (
|
|
93
|
+
isinstance(current, (int, float))
|
|
94
|
+
and not isinstance(current, bool)
|
|
95
|
+
and isinstance(total, (int, float))
|
|
96
|
+
and not isinstance(total, bool)
|
|
97
|
+
and math.isfinite(current)
|
|
98
|
+
and math.isfinite(total)
|
|
99
|
+
and 0 <= current <= total <= 1024**4
|
|
100
|
+
and total > 0
|
|
101
|
+
):
|
|
102
|
+
detail = f" {current / 1024 / 1024:.0f}/{total / 1024 / 1024:.0f} MB"
|
|
103
|
+
return f"{prefix}{action}{detail}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_selection_options(
|
|
107
|
+
harnesses: Sequence[str],
|
|
108
|
+
trials: int | None,
|
|
109
|
+
credential_env: Sequence[str],
|
|
110
|
+
execution_timeout: float | None = None,
|
|
111
|
+
runtime: str = "system",
|
|
112
|
+
) -> str | None:
|
|
113
|
+
if runtime not in {"system", "managed"}:
|
|
114
|
+
return "--runtime must be system or managed"
|
|
115
|
+
if execution_timeout is not None and (
|
|
116
|
+
not math.isfinite(execution_timeout) or execution_timeout <= 0
|
|
117
|
+
):
|
|
118
|
+
return "--execution-timeout must be a positive finite number"
|
|
119
|
+
if trials is not None and (isinstance(trials, bool) or trials <= 0):
|
|
120
|
+
return "--trials must be a positive integer"
|
|
121
|
+
selected: set[tuple[str, str]] = set()
|
|
122
|
+
for raw in harnesses:
|
|
123
|
+
if "=" not in raw:
|
|
124
|
+
return "--harness requires KIND=MODEL[,MODEL...]"
|
|
125
|
+
raw_kind, models = raw.split("=", 1)
|
|
126
|
+
raw_kind = raw_kind.strip()
|
|
127
|
+
version: str | None = None
|
|
128
|
+
if "@" in raw_kind:
|
|
129
|
+
base, version = raw_kind.split("@", 1)
|
|
130
|
+
if runtime != "managed":
|
|
131
|
+
return "versioned harness selectors require --runtime managed"
|
|
132
|
+
if (
|
|
133
|
+
not base.strip()
|
|
134
|
+
or len(version) > 64
|
|
135
|
+
or not _RUNTIME_VERSION.fullmatch(version)
|
|
136
|
+
):
|
|
137
|
+
return "--harness has an invalid version"
|
|
138
|
+
kind = base.strip().lower().replace("-", "_")
|
|
139
|
+
else:
|
|
140
|
+
kind = raw_kind.lower().replace("-", "_")
|
|
141
|
+
if kind == "claude":
|
|
142
|
+
kind = "claude_code"
|
|
143
|
+
selector = f"{kind}@{version}" if version is not None else kind
|
|
144
|
+
if kind not in _HARNESS_KINDS:
|
|
145
|
+
return f"unknown harness kind {kind!r}"
|
|
146
|
+
if runtime == "managed" and kind == "acp":
|
|
147
|
+
return "ACP does not support --runtime managed"
|
|
148
|
+
if not models.strip() or any(not model.strip() for model in models.split(",")):
|
|
149
|
+
return "--harness contains an empty model"
|
|
150
|
+
for model in models.split(","):
|
|
151
|
+
choice = (selector, model.strip())
|
|
152
|
+
if choice in selected:
|
|
153
|
+
return f"duplicate harness/model selection {kind}={model.strip()}"
|
|
154
|
+
selected.add(choice)
|
|
155
|
+
seen: set[tuple[str | None, str]] = set()
|
|
156
|
+
for raw in credential_env:
|
|
157
|
+
if "=" not in raw:
|
|
158
|
+
return "--credential-env requires TARGET=SOURCE"
|
|
159
|
+
target, source = raw.split("=", 1)
|
|
160
|
+
scope: str | None = None
|
|
161
|
+
if ":" in target:
|
|
162
|
+
scope, target = target.split(":", 1)
|
|
163
|
+
scope = scope.strip().lower().replace("-", "_")
|
|
164
|
+
if scope == "claude":
|
|
165
|
+
scope = "claude_code"
|
|
166
|
+
if scope not in _HARNESS_KINDS | {"judge"}:
|
|
167
|
+
return f"unknown credential scope {scope!r}"
|
|
168
|
+
target, source = target.strip(), source.strip()
|
|
169
|
+
if not _ENV_NAME.fullmatch(target) or not _ENV_NAME.fullmatch(source):
|
|
170
|
+
return "credential environment names must be Python identifiers"
|
|
171
|
+
key = (scope, target)
|
|
172
|
+
if key in seen:
|
|
173
|
+
return f"duplicate credential target {target!r}"
|
|
174
|
+
seen.add(key)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class StoredRun:
|
|
180
|
+
"""The small public-store projection needed by the CLI."""
|
|
181
|
+
|
|
182
|
+
run_id: str
|
|
183
|
+
created_at: datetime
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class StoredRuns:
|
|
188
|
+
"""A run listing and a safe warning when the database could not be read."""
|
|
189
|
+
|
|
190
|
+
runs: tuple[StoredRun, ...] = ()
|
|
191
|
+
warning: str | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class TestRunResult:
|
|
196
|
+
"""Pytest status plus runs found while preparing the UI."""
|
|
197
|
+
|
|
198
|
+
exit_code: int
|
|
199
|
+
new_runs: tuple[StoredRun, ...] = ()
|
|
200
|
+
warnings: tuple[str, ...] = ()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _absolute_path(value: str | os.PathLike[str]) -> Path:
|
|
204
|
+
"""Make a path absolute without resolving a venv's Python symlink."""
|
|
205
|
+
|
|
206
|
+
return Path(value).expanduser().absolute()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ProjectPythonError(ValueError):
|
|
210
|
+
"""A safe, actionable error while finding or checking project Python."""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _TerminationSignal(BaseException):
|
|
214
|
+
def __init__(self, signum: int) -> None:
|
|
215
|
+
self.signum = signum
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@contextmanager
|
|
219
|
+
def _termination_signal_handlers() -> Any:
|
|
220
|
+
"""Turn termination signals into cleanup-safe control flow."""
|
|
221
|
+
|
|
222
|
+
if os.name != "posix" or threading.current_thread() is not threading.main_thread():
|
|
223
|
+
yield
|
|
224
|
+
return
|
|
225
|
+
handled = tuple(
|
|
226
|
+
candidate
|
|
227
|
+
for candidate in (
|
|
228
|
+
getattr(signal, "SIGTERM", None),
|
|
229
|
+
getattr(signal, "SIGHUP", None),
|
|
230
|
+
)
|
|
231
|
+
if candidate is not None
|
|
232
|
+
)
|
|
233
|
+
previous: dict[int, Any] = {}
|
|
234
|
+
|
|
235
|
+
def terminate(signum: int, _frame: Any) -> None:
|
|
236
|
+
raise _TerminationSignal(signum)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
for candidate in handled:
|
|
240
|
+
previous[int(candidate)] = signal.getsignal(candidate)
|
|
241
|
+
signal.signal(candidate, terminate)
|
|
242
|
+
yield
|
|
243
|
+
finally:
|
|
244
|
+
for candidate, handler in previous.items():
|
|
245
|
+
signal.signal(candidate, handler)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _python_in_environment(root: Path, environment: Mapping[str, str]) -> Path | None:
|
|
249
|
+
virtual = environment.get("VIRTUAL_ENV")
|
|
250
|
+
if virtual:
|
|
251
|
+
base = Path(virtual).expanduser()
|
|
252
|
+
executable = base / ("Scripts" if os.name == "nt" else "bin") / "python"
|
|
253
|
+
if os.name == "nt" and not executable.exists():
|
|
254
|
+
executable = executable.with_suffix(".exe")
|
|
255
|
+
if executable.is_file():
|
|
256
|
+
return _absolute_path(executable)
|
|
257
|
+
conda = environment.get("CONDA_PREFIX")
|
|
258
|
+
if conda:
|
|
259
|
+
base = Path(conda).expanduser()
|
|
260
|
+
names = (
|
|
261
|
+
[(base / "python.exe")] if os.name == "nt" else [base / "bin" / "python"]
|
|
262
|
+
)
|
|
263
|
+
names.append(
|
|
264
|
+
base / "bin" / "python.exe" if os.name == "nt" else base / "python"
|
|
265
|
+
)
|
|
266
|
+
for executable in names:
|
|
267
|
+
if executable.is_file():
|
|
268
|
+
return _absolute_path(executable)
|
|
269
|
+
venv = root / ".venv" / ("Scripts" if os.name == "nt" else "bin") / "python"
|
|
270
|
+
if os.name == "nt" and not venv.exists():
|
|
271
|
+
venv = venv.with_suffix(".exe")
|
|
272
|
+
if venv.is_file():
|
|
273
|
+
return _absolute_path(venv)
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _explicit_python(value: str | os.PathLike[str], *, path: str | None = None) -> Path:
|
|
278
|
+
text = os.fspath(value)
|
|
279
|
+
if os.sep not in text and (os.altsep is None or os.altsep not in text):
|
|
280
|
+
found = shutil.which(text, path=path)
|
|
281
|
+
if found:
|
|
282
|
+
return _absolute_path(found)
|
|
283
|
+
return _absolute_path(text)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def resolve_project_python(
|
|
287
|
+
explicit: str | os.PathLike[str] | None = None,
|
|
288
|
+
*,
|
|
289
|
+
project_root: Path | None = None,
|
|
290
|
+
environment: Mapping[str, str] | None = None,
|
|
291
|
+
fallback_to_system: bool = True,
|
|
292
|
+
) -> Path:
|
|
293
|
+
"""Resolve Python in the documented order, without importing project code."""
|
|
294
|
+
|
|
295
|
+
root = (project_root or Path.cwd()).resolve()
|
|
296
|
+
env = os.environ if environment is None else environment
|
|
297
|
+
if explicit is not None:
|
|
298
|
+
return _explicit_python(explicit, path=env.get("PATH"))
|
|
299
|
+
if not fallback_to_system:
|
|
300
|
+
for variable in ("VIRTUAL_ENV", "CONDA_PREFIX"):
|
|
301
|
+
active = env.get(variable)
|
|
302
|
+
if active:
|
|
303
|
+
candidate = _python_in_environment(root, {variable: active})
|
|
304
|
+
if candidate is None:
|
|
305
|
+
raise ProjectPythonError(
|
|
306
|
+
"the active project environment is unavailable"
|
|
307
|
+
)
|
|
308
|
+
return candidate
|
|
309
|
+
selected = _python_in_environment(root, env)
|
|
310
|
+
if selected is not None:
|
|
311
|
+
return selected
|
|
312
|
+
if not fallback_to_system:
|
|
313
|
+
raise ProjectPythonError("no project environment is configured; run m3 setup")
|
|
314
|
+
for name in ("python3", "python"):
|
|
315
|
+
found = shutil.which(name, path=env.get("PATH"))
|
|
316
|
+
if found:
|
|
317
|
+
return _absolute_path(found)
|
|
318
|
+
raise ProjectPythonError("no project Python was found; pass --python PATH")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
_VALIDATE_SCRIPT = r"""
|
|
322
|
+
import importlib
|
|
323
|
+
import importlib.metadata
|
|
324
|
+
import json
|
|
325
|
+
|
|
326
|
+
checks = {}
|
|
327
|
+
for name in ("pytest", "m3", "m3.pytest_plugin", "openai"):
|
|
328
|
+
try:
|
|
329
|
+
importlib.import_module(name)
|
|
330
|
+
except Exception:
|
|
331
|
+
checks[name] = False
|
|
332
|
+
else:
|
|
333
|
+
checks[name] = True
|
|
334
|
+
try:
|
|
335
|
+
from m3.storage import SQLiteExecutionStore
|
|
336
|
+
except Exception:
|
|
337
|
+
checks["SQLiteExecutionStore"] = False
|
|
338
|
+
else:
|
|
339
|
+
checks["SQLiteExecutionStore"] = True
|
|
340
|
+
try:
|
|
341
|
+
version = importlib.metadata.version("sf-m3")
|
|
342
|
+
except Exception:
|
|
343
|
+
version = None
|
|
344
|
+
print(json.dumps({"checks": checks, "version": version}, sort_keys=True))
|
|
345
|
+
"""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _cli_sdk_version() -> str:
|
|
349
|
+
try:
|
|
350
|
+
return importlib.metadata.version("sf-m3")
|
|
351
|
+
except importlib.metadata.PackageNotFoundError as exc:
|
|
352
|
+
raise ProjectPythonError(
|
|
353
|
+
"the CLI SDK version is unavailable; reinstall sf-m3-cli"
|
|
354
|
+
) from exc
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def validate_project_python(
|
|
358
|
+
python: Path,
|
|
359
|
+
*,
|
|
360
|
+
cli_sdk_version: str | None = None,
|
|
361
|
+
project_root: Path | None = None,
|
|
362
|
+
) -> str:
|
|
363
|
+
"""Check project imports in a child process and enforce the SDK version."""
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
result = subprocess.run(
|
|
367
|
+
[str(python), "-c", _VALIDATE_SCRIPT],
|
|
368
|
+
cwd=str((project_root or Path.cwd()).resolve()),
|
|
369
|
+
capture_output=True,
|
|
370
|
+
text=True,
|
|
371
|
+
check=False,
|
|
372
|
+
timeout=15,
|
|
373
|
+
)
|
|
374
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
375
|
+
raise ProjectPythonError(
|
|
376
|
+
"the selected project Python could not be started"
|
|
377
|
+
) from None
|
|
378
|
+
if result.returncode != 0:
|
|
379
|
+
raise ProjectPythonError("the selected project Python could not be started")
|
|
380
|
+
try:
|
|
381
|
+
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
382
|
+
except (IndexError, json.JSONDecodeError, TypeError):
|
|
383
|
+
raise ProjectPythonError(
|
|
384
|
+
"the selected project Python returned an invalid check result"
|
|
385
|
+
) from None
|
|
386
|
+
checks = payload.get("checks")
|
|
387
|
+
if not isinstance(checks, dict):
|
|
388
|
+
raise ProjectPythonError(
|
|
389
|
+
"the selected project Python returned an invalid check result"
|
|
390
|
+
)
|
|
391
|
+
missing = [
|
|
392
|
+
name
|
|
393
|
+
for name in (
|
|
394
|
+
"pytest",
|
|
395
|
+
"m3",
|
|
396
|
+
"m3.pytest_plugin",
|
|
397
|
+
"openai",
|
|
398
|
+
"SQLiteExecutionStore",
|
|
399
|
+
)
|
|
400
|
+
if not checks.get(name)
|
|
401
|
+
]
|
|
402
|
+
if missing:
|
|
403
|
+
names = ", ".join(missing)
|
|
404
|
+
raise ProjectPythonError(
|
|
405
|
+
f"the project Python is missing required M3 packages: {names}; run m3 setup in the project"
|
|
406
|
+
)
|
|
407
|
+
project_version = payload.get("version")
|
|
408
|
+
expected = _cli_sdk_version() if cli_sdk_version is None else cli_sdk_version
|
|
409
|
+
if not isinstance(project_version, str):
|
|
410
|
+
raise ProjectPythonError(
|
|
411
|
+
"the project m3 distribution version could not be determined"
|
|
412
|
+
)
|
|
413
|
+
if project_version != expected:
|
|
414
|
+
raise ProjectPythonError(
|
|
415
|
+
f"project m3 version {project_version} does not match CLI SDK version {expected}; install matching versions"
|
|
416
|
+
)
|
|
417
|
+
return project_version
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _absolute_database(
|
|
421
|
+
value: str | os.PathLike[str] | None, *, project_root: Path | None = None
|
|
422
|
+
) -> Path:
|
|
423
|
+
root = (project_root or Path.cwd()).resolve()
|
|
424
|
+
return (
|
|
425
|
+
Path(value).expanduser() if value else root / ".m3" / "executions.sqlite"
|
|
426
|
+
).resolve()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _test_environment(env_file: str | os.PathLike[str] | None) -> dict[str, str] | None:
|
|
430
|
+
"""Return a child environment with explicitly requested dotenv values."""
|
|
431
|
+
if env_file is None:
|
|
432
|
+
return None
|
|
433
|
+
path = Path(env_file).expanduser()
|
|
434
|
+
if not path.is_file():
|
|
435
|
+
raise ProjectPythonError(f"env file was not found: {path}")
|
|
436
|
+
try:
|
|
437
|
+
from dotenv import dotenv_values
|
|
438
|
+
|
|
439
|
+
values = dotenv_values(str(path), interpolate=False)
|
|
440
|
+
except Exception as exc:
|
|
441
|
+
raise ProjectPythonError(f"could not read env file: {path}") from exc
|
|
442
|
+
child = dict(os.environ)
|
|
443
|
+
for key, value in values.items():
|
|
444
|
+
if key and value is not None and key not in child:
|
|
445
|
+
child[key] = value
|
|
446
|
+
return child
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _execution_store_type() -> Any:
|
|
450
|
+
"""Load the optional SQLite store only when run discovery is requested."""
|
|
451
|
+
|
|
452
|
+
from m3.storage import SQLiteExecutionStore
|
|
453
|
+
|
|
454
|
+
return SQLiteExecutionStore
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def list_stored_runs(database: Path) -> StoredRuns:
|
|
458
|
+
"""List pytest run manifests, whose IDs identify feedback reports."""
|
|
459
|
+
|
|
460
|
+
store: Any | None = None
|
|
461
|
+
try:
|
|
462
|
+
store = _execution_store_type()(database)
|
|
463
|
+
runs: list[StoredRun] = []
|
|
464
|
+
for manifest in store.list_test_runs():
|
|
465
|
+
run_id = manifest.get("run_id")
|
|
466
|
+
created_at = manifest.get("created_at")
|
|
467
|
+
if (
|
|
468
|
+
not isinstance(run_id, str)
|
|
469
|
+
or not run_id
|
|
470
|
+
or not isinstance(created_at, str)
|
|
471
|
+
):
|
|
472
|
+
continue
|
|
473
|
+
try:
|
|
474
|
+
timestamp = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
475
|
+
except ValueError:
|
|
476
|
+
continue
|
|
477
|
+
if timestamp.utcoffset() is None:
|
|
478
|
+
continue
|
|
479
|
+
runs.append(StoredRun(run_id=run_id, created_at=timestamp))
|
|
480
|
+
return StoredRuns(tuple(runs))
|
|
481
|
+
except Exception:
|
|
482
|
+
# The test process must remain useful even when an old, locked, or
|
|
483
|
+
# unreadable history database cannot be inspected.
|
|
484
|
+
return StoredRuns(warning="could not read stored run history")
|
|
485
|
+
finally:
|
|
486
|
+
if store is not None:
|
|
487
|
+
try:
|
|
488
|
+
store.close()
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
_BASELINE_SCRIPT = r"""
|
|
494
|
+
import json
|
|
495
|
+
import sys
|
|
496
|
+
from m3.storage import SQLiteExecutionStore
|
|
497
|
+
|
|
498
|
+
store = SQLiteExecutionStore(sys.argv[1])
|
|
499
|
+
try:
|
|
500
|
+
run_id = sys.argv[2]
|
|
501
|
+
expected_project = sys.argv[3] or None
|
|
502
|
+
manifest = store.get_test_run(run_id)
|
|
503
|
+
page = store.list_executions(limit=1, offset=0, run_id=run_id)
|
|
504
|
+
found = manifest is not None or bool(page.items)
|
|
505
|
+
actual_project = manifest.get("project_id") if manifest else None
|
|
506
|
+
if actual_project is None and page.items:
|
|
507
|
+
project = page.items[0].project_id
|
|
508
|
+
actual_project = project.root if project is not None else None
|
|
509
|
+
if expected_project is not None:
|
|
510
|
+
found = found and actual_project == expected_project
|
|
511
|
+
finally:
|
|
512
|
+
close = getattr(store, "close", None)
|
|
513
|
+
if callable(close):
|
|
514
|
+
close()
|
|
515
|
+
print(json.dumps({"found": bool(found)}))
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _project_id(root: Path) -> str | None:
|
|
520
|
+
path = root / "m3.toml"
|
|
521
|
+
if not path.is_file():
|
|
522
|
+
return None
|
|
523
|
+
try:
|
|
524
|
+
value = _tomllib.loads(path.read_text(encoding="utf-8"))
|
|
525
|
+
from uuid import UUID
|
|
526
|
+
|
|
527
|
+
identifier = str(value["project_id"])
|
|
528
|
+
return identifier if str(UUID(identifier)) == identifier else None
|
|
529
|
+
except (OSError, UnicodeError, ValueError, KeyError, TypeError):
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def baseline_exists(
|
|
534
|
+
database: Path,
|
|
535
|
+
run_id: str,
|
|
536
|
+
*,
|
|
537
|
+
python: Path | None = None,
|
|
538
|
+
project_root: Path | None = None,
|
|
539
|
+
project_id: str | None = None,
|
|
540
|
+
) -> bool:
|
|
541
|
+
"""Validate an explicit baseline before starting project pytest.
|
|
542
|
+
|
|
543
|
+
When project Python is supplied, the check runs through that environment so
|
|
544
|
+
the CLI never interprets a project database using a different SDK build.
|
|
545
|
+
"""
|
|
546
|
+
if python is not None:
|
|
547
|
+
try:
|
|
548
|
+
result = subprocess.run(
|
|
549
|
+
[
|
|
550
|
+
str(python),
|
|
551
|
+
"-c",
|
|
552
|
+
_BASELINE_SCRIPT,
|
|
553
|
+
str(database),
|
|
554
|
+
str(run_id),
|
|
555
|
+
project_id or "",
|
|
556
|
+
],
|
|
557
|
+
cwd=str((project_root or Path.cwd()).resolve()),
|
|
558
|
+
capture_output=True,
|
|
559
|
+
text=True,
|
|
560
|
+
check=False,
|
|
561
|
+
timeout=15,
|
|
562
|
+
)
|
|
563
|
+
payload = json.loads(result.stdout.strip().splitlines()[-1])
|
|
564
|
+
return result.returncode == 0 and bool(payload.get("found"))
|
|
565
|
+
except (
|
|
566
|
+
OSError,
|
|
567
|
+
subprocess.TimeoutExpired,
|
|
568
|
+
IndexError,
|
|
569
|
+
json.JSONDecodeError,
|
|
570
|
+
AttributeError,
|
|
571
|
+
):
|
|
572
|
+
return False
|
|
573
|
+
store: Any | None = None
|
|
574
|
+
try:
|
|
575
|
+
store = _execution_store_type()(database)
|
|
576
|
+
get_manifest = getattr(store, "get_test_run", None)
|
|
577
|
+
manifest = get_manifest(run_id) if callable(get_manifest) else None
|
|
578
|
+
page = store.list_executions(limit=1, offset=0, run_id=run_id)
|
|
579
|
+
if manifest is None and not page.items:
|
|
580
|
+
return False
|
|
581
|
+
if project_id is None:
|
|
582
|
+
return True
|
|
583
|
+
actual = manifest.get("project_id") if manifest else None
|
|
584
|
+
if actual is None and page.items:
|
|
585
|
+
value = page.items[0].project_id
|
|
586
|
+
actual = value.root if value is not None else None
|
|
587
|
+
return actual == project_id
|
|
588
|
+
except Exception:
|
|
589
|
+
return False
|
|
590
|
+
finally:
|
|
591
|
+
if store is not None:
|
|
592
|
+
try:
|
|
593
|
+
store.close()
|
|
594
|
+
except Exception:
|
|
595
|
+
pass
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def find_new_runs(before: StoredRuns, after: StoredRuns) -> tuple[StoredRun, ...]:
|
|
599
|
+
"""Return after-runs whose IDs were absent before pytest started."""
|
|
600
|
+
|
|
601
|
+
existing = {run.run_id for run in before.runs}
|
|
602
|
+
unique: dict[str, StoredRun] = {}
|
|
603
|
+
for run in after.runs:
|
|
604
|
+
if run.run_id not in existing:
|
|
605
|
+
unique[run.run_id] = run
|
|
606
|
+
return tuple(sorted(unique.values(), key=lambda run: (run.created_at, run.run_id)))
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def build_run_url(run_id: str, port: int, auth_token: str | None = None) -> str:
|
|
610
|
+
"""Build an encoded feedback report URL for the local UI."""
|
|
611
|
+
|
|
612
|
+
origin = f"http://127.0.0.1:{port}"
|
|
613
|
+
encoded_run_id = quote(str(run_id), safe="")
|
|
614
|
+
url = f"{origin}/reports/runs/{encoded_run_id}"
|
|
615
|
+
return f"{url}#m3_token={auth_token}" if auth_token is not None else url
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def build_home_url(port: int, auth_token: str) -> str:
|
|
619
|
+
"""Build the CLI's authenticated UI entry link."""
|
|
620
|
+
|
|
621
|
+
return f"http://127.0.0.1:{port}/#m3_token={auth_token}"
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _validate_port(port: int) -> str | None:
|
|
625
|
+
if not 1 <= port <= 65535:
|
|
626
|
+
return "port must be between 1 and 65535"
|
|
627
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
628
|
+
try:
|
|
629
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
630
|
+
probe.bind(("127.0.0.1", port))
|
|
631
|
+
except OSError:
|
|
632
|
+
return "port is already in use"
|
|
633
|
+
finally:
|
|
634
|
+
probe.close()
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _ui_prerequisite_error(ui_dir: str | os.PathLike[str] | None) -> str | None:
|
|
639
|
+
try:
|
|
640
|
+
from .web import ui_directory
|
|
641
|
+
|
|
642
|
+
ui_directory(ui_dir)
|
|
643
|
+
except (OSError, TypeError, ValueError):
|
|
644
|
+
return "bundled M3 UI assets are unavailable"
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _server_command(database: Path, port: int) -> list[str]:
|
|
649
|
+
return [
|
|
650
|
+
sys.executable,
|
|
651
|
+
"-m",
|
|
652
|
+
"m3_cli.web",
|
|
653
|
+
"--database-path",
|
|
654
|
+
str(database),
|
|
655
|
+
"--port",
|
|
656
|
+
str(port),
|
|
657
|
+
]
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class _ServerChild:
|
|
661
|
+
_READY_MARKER = "\x00M3_UI_SERVER_READY\x00"
|
|
662
|
+
|
|
663
|
+
def __init__(self, database: Path, port: int, auth_token: str) -> None:
|
|
664
|
+
kwargs: dict[str, Any] = {
|
|
665
|
+
"env": _server_environment(),
|
|
666
|
+
"stdout": subprocess.PIPE,
|
|
667
|
+
"stderr": subprocess.STDOUT,
|
|
668
|
+
"stdin": subprocess.PIPE,
|
|
669
|
+
"text": True,
|
|
670
|
+
"bufsize": 1,
|
|
671
|
+
}
|
|
672
|
+
if os.name == "posix":
|
|
673
|
+
kwargs["start_new_session"] = True
|
|
674
|
+
elif os.name == "nt":
|
|
675
|
+
flags = int(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
|
|
676
|
+
if flags:
|
|
677
|
+
kwargs["creationflags"] = flags
|
|
678
|
+
self.lines: deque[str] = deque(maxlen=20)
|
|
679
|
+
self._startup_ready = threading.Event()
|
|
680
|
+
self.process = subprocess.Popen(_server_command(database, port), **kwargs)
|
|
681
|
+
if self.process.stdin is None:
|
|
682
|
+
_terminate_process(self.process)
|
|
683
|
+
raise OSError("could not deliver local UI credentials")
|
|
684
|
+
try:
|
|
685
|
+
self.process.stdin.write(auth_token + "\n")
|
|
686
|
+
self.process.stdin.flush()
|
|
687
|
+
except (OSError, ValueError):
|
|
688
|
+
_terminate_process(self.process)
|
|
689
|
+
raise OSError("could not deliver local UI credentials") from None
|
|
690
|
+
finally:
|
|
691
|
+
try:
|
|
692
|
+
self.process.stdin.close()
|
|
693
|
+
except (OSError, ValueError):
|
|
694
|
+
pass
|
|
695
|
+
threading.Thread(target=self._read, daemon=True).start()
|
|
696
|
+
|
|
697
|
+
def _read(self) -> None:
|
|
698
|
+
stream = self.process.stdout
|
|
699
|
+
if stream is None:
|
|
700
|
+
return
|
|
701
|
+
try:
|
|
702
|
+
for line in stream:
|
|
703
|
+
line = line.rstrip("\r\n")
|
|
704
|
+
if line == self._READY_MARKER:
|
|
705
|
+
self._startup_ready.set()
|
|
706
|
+
else:
|
|
707
|
+
self.lines.append(line)
|
|
708
|
+
except (OSError, ValueError):
|
|
709
|
+
pass
|
|
710
|
+
|
|
711
|
+
def alive(self) -> bool:
|
|
712
|
+
return self.process.poll() is None
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _wait_ready(child: _ServerChild, timeout: float = 20.0) -> bool:
|
|
716
|
+
deadline = time.monotonic() + timeout
|
|
717
|
+
while time.monotonic() < deadline:
|
|
718
|
+
if not child.alive():
|
|
719
|
+
return False
|
|
720
|
+
remaining = deadline - time.monotonic()
|
|
721
|
+
if child._startup_ready.wait(min(remaining, 0.05)):
|
|
722
|
+
return child.alive()
|
|
723
|
+
return False
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _server_environment(source: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
727
|
+
"""Isolate the viewer from project test settings and credentials."""
|
|
728
|
+
|
|
729
|
+
values = os.environ if source is None else source
|
|
730
|
+
environment_links = {
|
|
731
|
+
"VIRTUAL_ENV",
|
|
732
|
+
"CONDA_PREFIX",
|
|
733
|
+
"PYTHONPATH",
|
|
734
|
+
"UV_PROJECT_ENVIRONMENT",
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
key: value
|
|
738
|
+
for key, value in values.items()
|
|
739
|
+
if key.upper() not in environment_links
|
|
740
|
+
and not key.upper().startswith("M3_")
|
|
741
|
+
and not any(
|
|
742
|
+
part in key.upper() for part in ("KEY", "TOKEN", "SECRET", "PASSWORD")
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _server_diagnostics(
|
|
748
|
+
child: _ServerChild, auth_token: str | None = None
|
|
749
|
+
) -> tuple[str, ...]:
|
|
750
|
+
"""Return bounded server output with ambient credentials removed."""
|
|
751
|
+
|
|
752
|
+
secret_values = tuple(
|
|
753
|
+
value
|
|
754
|
+
for key, value in os.environ.items()
|
|
755
|
+
if value
|
|
756
|
+
and len(value) >= 4
|
|
757
|
+
and any(part in key.upper() for part in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
|
|
758
|
+
)
|
|
759
|
+
safe: list[str] = []
|
|
760
|
+
for raw in child.lines:
|
|
761
|
+
line = raw[:1000]
|
|
762
|
+
if auth_token:
|
|
763
|
+
line = line.replace(auth_token, "<redacted>")
|
|
764
|
+
for value in secret_values:
|
|
765
|
+
line = line.replace(value, "<redacted>")
|
|
766
|
+
line = re.sub(
|
|
767
|
+
r"(?i)(\b(?:api[_-]?key|token|secret|password|credential)\b\s*(?:=|:)\s*)[^\s,;]+",
|
|
768
|
+
r"\1<redacted>",
|
|
769
|
+
line,
|
|
770
|
+
)
|
|
771
|
+
safe.append(line)
|
|
772
|
+
return tuple(safe[-5:])
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def pytest_command(
|
|
776
|
+
python: Path,
|
|
777
|
+
database: Path,
|
|
778
|
+
pytest_args: Sequence[str],
|
|
779
|
+
*,
|
|
780
|
+
baseline: str | None = None,
|
|
781
|
+
project_root: Path | None = None,
|
|
782
|
+
harnesses: Sequence[str] = (),
|
|
783
|
+
trials: int | None = None,
|
|
784
|
+
suite: str | None = None,
|
|
785
|
+
credential_env: Sequence[str] = (),
|
|
786
|
+
execution_timeout: float | None = None,
|
|
787
|
+
judge_max_requests: int | None = None,
|
|
788
|
+
runtime: str = "system",
|
|
789
|
+
) -> list[str]:
|
|
790
|
+
command = [
|
|
791
|
+
str(python),
|
|
792
|
+
"-m",
|
|
793
|
+
"pytest",
|
|
794
|
+
"-p",
|
|
795
|
+
"m3.pytest_plugin",
|
|
796
|
+
"--results-db",
|
|
797
|
+
str(database),
|
|
798
|
+
]
|
|
799
|
+
if baseline:
|
|
800
|
+
command.extend(("--baseline", baseline))
|
|
801
|
+
if project_root is not None:
|
|
802
|
+
command.extend(("--project-root", str(project_root)))
|
|
803
|
+
if project_root is not None and not _has_rootdir_option(pytest_args):
|
|
804
|
+
command.extend(("--rootdir", str(project_root)))
|
|
805
|
+
for value in harnesses:
|
|
806
|
+
command.extend(("--harness", value))
|
|
807
|
+
if runtime == "managed":
|
|
808
|
+
command.extend(("--runtime", "managed"))
|
|
809
|
+
for value in credential_env:
|
|
810
|
+
command.extend(("--credential-env", value))
|
|
811
|
+
if trials is not None:
|
|
812
|
+
command.extend(("--trials", str(trials)))
|
|
813
|
+
if suite is not None:
|
|
814
|
+
command.extend(("--suite", suite))
|
|
815
|
+
if execution_timeout is not None:
|
|
816
|
+
command.extend(("--execution-timeout", str(execution_timeout)))
|
|
817
|
+
if judge_max_requests is not None:
|
|
818
|
+
command.extend(("--judge-max-requests", str(judge_max_requests)))
|
|
819
|
+
command.extend(pytest_args)
|
|
820
|
+
return command
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _has_rootdir_option(args: Sequence[str]) -> bool:
|
|
824
|
+
return any(arg == "--rootdir" or arg.startswith("--rootdir=") for arg in args)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
# Keep the implementation name easy to discover for callers that used the old
|
|
828
|
+
# supervisor's private command helper while making the Python argument explicit.
|
|
829
|
+
_pytest_command = pytest_command
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _terminate_process(process: Any) -> None:
|
|
833
|
+
try:
|
|
834
|
+
if os.name == "posix":
|
|
835
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
836
|
+
else:
|
|
837
|
+
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
|
|
838
|
+
process.send_signal(
|
|
839
|
+
ctrl_break
|
|
840
|
+
) if ctrl_break is not None else process.terminate()
|
|
841
|
+
except (OSError, ProcessLookupError):
|
|
842
|
+
pass
|
|
843
|
+
try:
|
|
844
|
+
process.wait(timeout=3)
|
|
845
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
846
|
+
try:
|
|
847
|
+
if os.name == "posix":
|
|
848
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
849
|
+
else:
|
|
850
|
+
process.kill()
|
|
851
|
+
except (OSError, ProcessLookupError):
|
|
852
|
+
pass
|
|
853
|
+
try:
|
|
854
|
+
process.wait(timeout=2)
|
|
855
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
856
|
+
pass
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _run_pytest_process(
|
|
860
|
+
python: Path,
|
|
861
|
+
database: Path,
|
|
862
|
+
pytest_args: Sequence[str],
|
|
863
|
+
*,
|
|
864
|
+
baseline: str | None = None,
|
|
865
|
+
project_root: Path | None = None,
|
|
866
|
+
harnesses: Sequence[str] = (),
|
|
867
|
+
trials: int | None = None,
|
|
868
|
+
suite: str | None = None,
|
|
869
|
+
credential_env: Sequence[str] = (),
|
|
870
|
+
execution_timeout: float | None = None,
|
|
871
|
+
judge_max_requests: int | None = None,
|
|
872
|
+
environment: Mapping[str, str] | None = None,
|
|
873
|
+
runtime: str = "system",
|
|
874
|
+
harness_cache_dir: str | os.PathLike[str] | None = None,
|
|
875
|
+
) -> int:
|
|
876
|
+
"""Run pytest with safe process-group cleanup and return its status."""
|
|
877
|
+
|
|
878
|
+
process: subprocess.Popen[Any] | None = None
|
|
879
|
+
runtime_dir: Path | None = None
|
|
880
|
+
progress: _ProgressStream | None = None
|
|
881
|
+
try:
|
|
882
|
+
with _termination_signal_handlers():
|
|
883
|
+
kwargs: dict[str, Any] = {}
|
|
884
|
+
if os.name == "posix":
|
|
885
|
+
kwargs["start_new_session"] = True
|
|
886
|
+
elif os.name == "nt":
|
|
887
|
+
flags = int(getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
|
|
888
|
+
if flags:
|
|
889
|
+
kwargs["creationflags"] = flags
|
|
890
|
+
if project_root is not None:
|
|
891
|
+
kwargs["cwd"] = str(project_root)
|
|
892
|
+
child_environment = (
|
|
893
|
+
dict(environment) if environment is not None else dict(os.environ)
|
|
894
|
+
)
|
|
895
|
+
if runtime == "managed":
|
|
896
|
+
runtime_dir = Path(tempfile.mkdtemp(prefix="m3-runtime-"))
|
|
897
|
+
os.chmod(runtime_dir, 0o700)
|
|
898
|
+
progress_path = runtime_dir / "progress.jsonl"
|
|
899
|
+
progress_path.touch(mode=0o600)
|
|
900
|
+
os.chmod(progress_path, 0o600)
|
|
901
|
+
pin_dir = runtime_dir / "invocation-pin"
|
|
902
|
+
pin_dir.mkdir(mode=0o700)
|
|
903
|
+
# The aliases keep the boundary compatible across runtime-core versions.
|
|
904
|
+
for key in (
|
|
905
|
+
"M3_RUNTIME_PROGRESS_FILE",
|
|
906
|
+
"M3_MANAGED_RUNTIME_PROGRESS_FILE",
|
|
907
|
+
):
|
|
908
|
+
child_environment[key] = str(progress_path)
|
|
909
|
+
for key in ("M3_INVOCATION_PIN_DIR", "M3_RUNTIME_INVOCATION_DIR"):
|
|
910
|
+
child_environment[key] = str(pin_dir)
|
|
911
|
+
if harness_cache_dir is not None:
|
|
912
|
+
child_environment["M3_HARNESS_CACHE_DIR"] = str(
|
|
913
|
+
Path(harness_cache_dir).expanduser().absolute()
|
|
914
|
+
)
|
|
915
|
+
progress = _ProgressStream(progress_path)
|
|
916
|
+
process = subprocess.Popen(
|
|
917
|
+
pytest_command(
|
|
918
|
+
python,
|
|
919
|
+
database,
|
|
920
|
+
pytest_args,
|
|
921
|
+
baseline=baseline,
|
|
922
|
+
project_root=project_root,
|
|
923
|
+
harnesses=harnesses,
|
|
924
|
+
trials=trials,
|
|
925
|
+
suite=suite,
|
|
926
|
+
credential_env=credential_env,
|
|
927
|
+
execution_timeout=execution_timeout,
|
|
928
|
+
judge_max_requests=judge_max_requests,
|
|
929
|
+
runtime=runtime,
|
|
930
|
+
),
|
|
931
|
+
env=child_environment,
|
|
932
|
+
**kwargs,
|
|
933
|
+
)
|
|
934
|
+
try:
|
|
935
|
+
while True:
|
|
936
|
+
if progress is not None:
|
|
937
|
+
progress.poll()
|
|
938
|
+
try:
|
|
939
|
+
raw_code = int(process.wait(timeout=0.2))
|
|
940
|
+
break
|
|
941
|
+
except subprocess.TimeoutExpired:
|
|
942
|
+
continue
|
|
943
|
+
if progress is not None:
|
|
944
|
+
progress.poll()
|
|
945
|
+
except KeyboardInterrupt:
|
|
946
|
+
_terminate_process(process)
|
|
947
|
+
return 130
|
|
948
|
+
return 128 + (-raw_code) if raw_code < 0 else raw_code
|
|
949
|
+
except _TerminationSignal as exc:
|
|
950
|
+
if process is not None and process.poll() is None:
|
|
951
|
+
_terminate_process(process)
|
|
952
|
+
return 128 + exc.signum
|
|
953
|
+
except (OSError, ValueError):
|
|
954
|
+
if process is not None and process.poll() is None:
|
|
955
|
+
_terminate_process(process)
|
|
956
|
+
print("m3 test: pytest could not be started", file=sys.stderr)
|
|
957
|
+
return OPERATIONAL_ERROR
|
|
958
|
+
finally:
|
|
959
|
+
if progress is not None:
|
|
960
|
+
progress.close()
|
|
961
|
+
if runtime_dir is not None:
|
|
962
|
+
shutil.rmtree(runtime_dir, ignore_errors=True)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
class _ProgressStream:
|
|
966
|
+
"""Bounded, replacement-safe JSONL progress reader."""
|
|
967
|
+
|
|
968
|
+
def __init__(self, path: Path) -> None:
|
|
969
|
+
self.path = path
|
|
970
|
+
self.offset = 0
|
|
971
|
+
self._inode: tuple[int, int] | None = None
|
|
972
|
+
self.total = 0
|
|
973
|
+
self._pending = b""
|
|
974
|
+
self._discard_long_line = False
|
|
975
|
+
self._last: tuple[str, str] | None = None
|
|
976
|
+
self._last_print = 0.0
|
|
977
|
+
|
|
978
|
+
def poll(self) -> None:
|
|
979
|
+
if self.total >= _PROGRESS_TOTAL_LIMIT:
|
|
980
|
+
return
|
|
981
|
+
try:
|
|
982
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
983
|
+
descriptor = os.open(self.path, flags)
|
|
984
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
985
|
+
info = os.fstat(stream.fileno())
|
|
986
|
+
inode = (info.st_dev, info.st_ino)
|
|
987
|
+
if self._inode != inode or info.st_size < self.offset:
|
|
988
|
+
self.offset = 0
|
|
989
|
+
self._pending = b""
|
|
990
|
+
self._discard_long_line = False
|
|
991
|
+
self._inode = inode
|
|
992
|
+
if info.st_size <= self.offset:
|
|
993
|
+
return
|
|
994
|
+
stream.seek(self.offset)
|
|
995
|
+
data = stream.read(
|
|
996
|
+
min(
|
|
997
|
+
info.st_size - self.offset,
|
|
998
|
+
_PROGRESS_TOTAL_LIMIT - self.total,
|
|
999
|
+
1024 * 1024,
|
|
1000
|
+
)
|
|
1001
|
+
)
|
|
1002
|
+
self.offset += len(data)
|
|
1003
|
+
except OSError:
|
|
1004
|
+
return
|
|
1005
|
+
self.total += len(data)
|
|
1006
|
+
chunks = (self._pending + data).split(b"\n")
|
|
1007
|
+
discard_first = self._discard_long_line
|
|
1008
|
+
self._discard_long_line = False
|
|
1009
|
+
self._pending = chunks.pop()
|
|
1010
|
+
if len(self._pending) > _PROGRESS_LINE_LIMIT:
|
|
1011
|
+
self._pending = b""
|
|
1012
|
+
self._discard_long_line = True
|
|
1013
|
+
for index, raw in enumerate(chunks):
|
|
1014
|
+
if discard_first and index == 0:
|
|
1015
|
+
continue
|
|
1016
|
+
if len(raw) > _PROGRESS_LINE_LIMIT:
|
|
1017
|
+
continue
|
|
1018
|
+
try:
|
|
1019
|
+
event = json.loads(raw.decode("utf-8"))
|
|
1020
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
1021
|
+
continue
|
|
1022
|
+
if not isinstance(event, dict):
|
|
1023
|
+
continue
|
|
1024
|
+
message = _format_progress_event(event)
|
|
1025
|
+
if message is None:
|
|
1026
|
+
continue
|
|
1027
|
+
resolved = event.get("resolved_version")
|
|
1028
|
+
if resolved is not None and not isinstance(resolved, str):
|
|
1029
|
+
continue
|
|
1030
|
+
key = (message, resolved or "")
|
|
1031
|
+
now = time.monotonic()
|
|
1032
|
+
if key == self._last and now - self._last_print < 0.2:
|
|
1033
|
+
continue
|
|
1034
|
+
self._last, self._last_print = key, now
|
|
1035
|
+
print(f"m3: {message}", flush=True)
|
|
1036
|
+
|
|
1037
|
+
def close(self) -> None:
|
|
1038
|
+
self.poll()
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _prepare_test(
|
|
1042
|
+
python: str | os.PathLike[str] | None,
|
|
1043
|
+
database: str | os.PathLike[str] | None,
|
|
1044
|
+
root: Path,
|
|
1045
|
+
) -> tuple[Path, Path] | None:
|
|
1046
|
+
database_path = _absolute_database(database, project_root=root)
|
|
1047
|
+
try:
|
|
1048
|
+
database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1049
|
+
selected = resolve_project_python(python, project_root=root)
|
|
1050
|
+
validate_project_python(selected, project_root=root)
|
|
1051
|
+
except (OSError, ProjectPythonError) as exc:
|
|
1052
|
+
print(f"m3 test: {exc}", file=sys.stderr)
|
|
1053
|
+
return None
|
|
1054
|
+
return selected, database_path
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _stop_server(child: _ServerChild | None) -> None:
|
|
1058
|
+
if child is None:
|
|
1059
|
+
return
|
|
1060
|
+
if child.process.poll() is None:
|
|
1061
|
+
_terminate_process(child.process)
|
|
1062
|
+
else:
|
|
1063
|
+
try:
|
|
1064
|
+
child.process.wait(timeout=1)
|
|
1065
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1066
|
+
pass
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _print_ui_output(
|
|
1070
|
+
port: int,
|
|
1071
|
+
auth_token: str,
|
|
1072
|
+
new_runs: Sequence[StoredRun],
|
|
1073
|
+
warnings: Sequence[str],
|
|
1074
|
+
) -> None:
|
|
1075
|
+
for warning in dict.fromkeys(warnings):
|
|
1076
|
+
print(f"Warning: {warning}", file=sys.stderr)
|
|
1077
|
+
print(f"UI: {build_home_url(port, auth_token)}", flush=True)
|
|
1078
|
+
if not new_runs:
|
|
1079
|
+
print("No new stored runs.", flush=True)
|
|
1080
|
+
return
|
|
1081
|
+
for run in new_runs:
|
|
1082
|
+
print(f"Run: {build_run_url(run.run_id, port, auth_token)}", flush=True)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _run_ui_server(
|
|
1086
|
+
database: Path,
|
|
1087
|
+
port: int,
|
|
1088
|
+
pytest_code: int,
|
|
1089
|
+
new_runs: Sequence[StoredRun],
|
|
1090
|
+
warnings: Sequence[str],
|
|
1091
|
+
) -> int:
|
|
1092
|
+
child: _ServerChild | None = None
|
|
1093
|
+
auth_token = secrets.token_urlsafe(32)
|
|
1094
|
+
try:
|
|
1095
|
+
child = _ServerChild(database, port, auth_token)
|
|
1096
|
+
if not _wait_ready(child):
|
|
1097
|
+
print("m3: UI server readiness failed", file=sys.stderr)
|
|
1098
|
+
for line in _server_diagnostics(child, auth_token):
|
|
1099
|
+
print(f"m3: UI server: {line}", file=sys.stderr)
|
|
1100
|
+
return OPERATIONAL_ERROR
|
|
1101
|
+
print(M3_ASCII_ART, flush=True)
|
|
1102
|
+
_print_ui_output(port, auth_token, new_runs, warnings)
|
|
1103
|
+
with _termination_signal_handlers():
|
|
1104
|
+
while True:
|
|
1105
|
+
if not child.alive():
|
|
1106
|
+
print("m3: UI server stopped unexpectedly", file=sys.stderr)
|
|
1107
|
+
return OPERATIONAL_ERROR
|
|
1108
|
+
time.sleep(0.1)
|
|
1109
|
+
except KeyboardInterrupt:
|
|
1110
|
+
return pytest_code
|
|
1111
|
+
except _TerminationSignal as exc:
|
|
1112
|
+
return 128 + exc.signum
|
|
1113
|
+
except (OSError, ValueError):
|
|
1114
|
+
print("m3: UI server could not be started", file=sys.stderr)
|
|
1115
|
+
return OPERATIONAL_ERROR
|
|
1116
|
+
finally:
|
|
1117
|
+
_stop_server(child)
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def run_test_with_runs(
|
|
1121
|
+
*,
|
|
1122
|
+
python: str | os.PathLike[str] | None = None,
|
|
1123
|
+
pytest_args: Sequence[str] = (),
|
|
1124
|
+
database: str | os.PathLike[str] | None = None,
|
|
1125
|
+
ui: bool = False,
|
|
1126
|
+
port: int = 8000,
|
|
1127
|
+
ui_dir: str | os.PathLike[str] | None = None,
|
|
1128
|
+
project_root: Path | None = None,
|
|
1129
|
+
baseline: str | None = None,
|
|
1130
|
+
harnesses: Sequence[str] = (),
|
|
1131
|
+
trials: int | None = None,
|
|
1132
|
+
suite: str | None = None,
|
|
1133
|
+
credential_env: Sequence[str] = (),
|
|
1134
|
+
env_file: str | os.PathLike[str] | None = None,
|
|
1135
|
+
execution_timeout: float | None = None,
|
|
1136
|
+
judge_max_requests: int | None = None,
|
|
1137
|
+
runtime: str = "system",
|
|
1138
|
+
harness_cache_dir: str | os.PathLike[str] | None = None,
|
|
1139
|
+
) -> TestRunResult:
|
|
1140
|
+
"""Run pytest and retain newly stored runs for optional UI serving."""
|
|
1141
|
+
|
|
1142
|
+
option_error = _validate_selection_options(
|
|
1143
|
+
harnesses, trials, credential_env, execution_timeout, runtime
|
|
1144
|
+
)
|
|
1145
|
+
if suite is not None and not suite.strip():
|
|
1146
|
+
print("m3 test: --suite must not be blank", file=sys.stderr)
|
|
1147
|
+
return TestRunResult(2)
|
|
1148
|
+
if option_error is not None:
|
|
1149
|
+
print(f"m3 test: {option_error}", file=sys.stderr)
|
|
1150
|
+
return TestRunResult(OPERATIONAL_ERROR)
|
|
1151
|
+
|
|
1152
|
+
if ui:
|
|
1153
|
+
port_error = _validate_port(port)
|
|
1154
|
+
if port_error is not None:
|
|
1155
|
+
print(f"m3 test: UI startup failed ({port_error})", file=sys.stderr)
|
|
1156
|
+
return TestRunResult(OPERATIONAL_ERROR)
|
|
1157
|
+
ui_error = _ui_prerequisite_error(ui_dir)
|
|
1158
|
+
if ui_error is not None:
|
|
1159
|
+
print(f"m3 test: {ui_error}", file=sys.stderr)
|
|
1160
|
+
return TestRunResult(OPERATIONAL_ERROR)
|
|
1161
|
+
root = (project_root or Path.cwd()).resolve()
|
|
1162
|
+
database_path = _absolute_database(database, project_root=root)
|
|
1163
|
+
try:
|
|
1164
|
+
database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1165
|
+
except OSError:
|
|
1166
|
+
print(
|
|
1167
|
+
"m3 test: results database directory could not be created",
|
|
1168
|
+
file=sys.stderr,
|
|
1169
|
+
)
|
|
1170
|
+
return TestRunResult(OPERATIONAL_ERROR)
|
|
1171
|
+
|
|
1172
|
+
before = list_stored_runs(database_path)
|
|
1173
|
+
prepared = _prepare_test(python, database, root)
|
|
1174
|
+
if prepared is None:
|
|
1175
|
+
return TestRunResult(
|
|
1176
|
+
OPERATIONAL_ERROR, warnings=tuple(filter(None, (before.warning,)))
|
|
1177
|
+
)
|
|
1178
|
+
selected, database_path = prepared
|
|
1179
|
+
if baseline is not None and not baseline_exists(
|
|
1180
|
+
database_path,
|
|
1181
|
+
baseline,
|
|
1182
|
+
python=selected,
|
|
1183
|
+
project_root=root,
|
|
1184
|
+
project_id=_project_id(root),
|
|
1185
|
+
):
|
|
1186
|
+
print(f"m3 test: baseline run was not found: {baseline}", file=sys.stderr)
|
|
1187
|
+
return TestRunResult(
|
|
1188
|
+
OPERATIONAL_ERROR, warnings=tuple(filter(None, (before.warning,)))
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
try:
|
|
1192
|
+
child_environment = _test_environment(env_file)
|
|
1193
|
+
except ProjectPythonError as exc:
|
|
1194
|
+
print(f"m3 test: {exc}", file=sys.stderr)
|
|
1195
|
+
return TestRunResult(
|
|
1196
|
+
OPERATIONAL_ERROR, warnings=tuple(filter(None, (before.warning,)))
|
|
1197
|
+
)
|
|
1198
|
+
exit_code = _run_pytest_process(
|
|
1199
|
+
selected,
|
|
1200
|
+
database_path,
|
|
1201
|
+
pytest_args,
|
|
1202
|
+
baseline=baseline,
|
|
1203
|
+
project_root=root,
|
|
1204
|
+
harnesses=harnesses,
|
|
1205
|
+
trials=trials,
|
|
1206
|
+
credential_env=credential_env,
|
|
1207
|
+
execution_timeout=execution_timeout,
|
|
1208
|
+
judge_max_requests=judge_max_requests,
|
|
1209
|
+
suite=suite,
|
|
1210
|
+
environment=child_environment,
|
|
1211
|
+
runtime=runtime,
|
|
1212
|
+
harness_cache_dir=harness_cache_dir,
|
|
1213
|
+
)
|
|
1214
|
+
after = list_stored_runs(database_path)
|
|
1215
|
+
warnings = tuple(
|
|
1216
|
+
dict.fromkeys(
|
|
1217
|
+
warning
|
|
1218
|
+
for warning in (before.warning, after.warning)
|
|
1219
|
+
if warning is not None
|
|
1220
|
+
)
|
|
1221
|
+
)
|
|
1222
|
+
new_runs = find_new_runs(before, after)
|
|
1223
|
+
if not ui:
|
|
1224
|
+
return TestRunResult(exit_code, new_runs, warnings)
|
|
1225
|
+
if exit_code in {2, 130} or 128 <= exit_code < 256:
|
|
1226
|
+
return TestRunResult(exit_code, new_runs, warnings)
|
|
1227
|
+
server_code = _run_ui_server(database_path, port, exit_code, new_runs, warnings)
|
|
1228
|
+
return TestRunResult(server_code, new_runs, warnings)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def run_test(
|
|
1232
|
+
*,
|
|
1233
|
+
python: str | os.PathLike[str] | None = None,
|
|
1234
|
+
pytest_args: Sequence[str] = (),
|
|
1235
|
+
database: str | os.PathLike[str] | None = None,
|
|
1236
|
+
ui: bool = False,
|
|
1237
|
+
port: int = 8000,
|
|
1238
|
+
ui_dir: str | os.PathLike[str] | None = None,
|
|
1239
|
+
project_root: Path | None = None,
|
|
1240
|
+
baseline: str | None = None,
|
|
1241
|
+
harnesses: Sequence[str] = (),
|
|
1242
|
+
trials: int | None = None,
|
|
1243
|
+
suite: str | None = None,
|
|
1244
|
+
credential_env: Sequence[str] = (),
|
|
1245
|
+
env_file: str | os.PathLike[str] | None = None,
|
|
1246
|
+
execution_timeout: float | None = None,
|
|
1247
|
+
judge_max_requests: int | None = None,
|
|
1248
|
+
runtime: str = "system",
|
|
1249
|
+
harness_cache_dir: str | os.PathLike[str] | None = None,
|
|
1250
|
+
) -> int:
|
|
1251
|
+
"""Run pytest and return its exact exit status."""
|
|
1252
|
+
|
|
1253
|
+
if suite is not None and not suite.strip():
|
|
1254
|
+
print("m3 test: --suite must not be blank", file=sys.stderr)
|
|
1255
|
+
return 2
|
|
1256
|
+
|
|
1257
|
+
option_error = _validate_selection_options(
|
|
1258
|
+
harnesses, trials, credential_env, execution_timeout, runtime
|
|
1259
|
+
)
|
|
1260
|
+
if option_error is not None:
|
|
1261
|
+
print(f"m3 test: {option_error}", file=sys.stderr)
|
|
1262
|
+
return OPERATIONAL_ERROR
|
|
1263
|
+
|
|
1264
|
+
if ui:
|
|
1265
|
+
return run_test_with_runs(
|
|
1266
|
+
python=python,
|
|
1267
|
+
pytest_args=pytest_args,
|
|
1268
|
+
database=database,
|
|
1269
|
+
ui=ui,
|
|
1270
|
+
port=port,
|
|
1271
|
+
ui_dir=ui_dir,
|
|
1272
|
+
project_root=project_root,
|
|
1273
|
+
baseline=baseline,
|
|
1274
|
+
harnesses=harnesses,
|
|
1275
|
+
trials=trials,
|
|
1276
|
+
suite=suite,
|
|
1277
|
+
credential_env=credential_env,
|
|
1278
|
+
env_file=env_file,
|
|
1279
|
+
execution_timeout=execution_timeout,
|
|
1280
|
+
judge_max_requests=judge_max_requests,
|
|
1281
|
+
runtime=runtime,
|
|
1282
|
+
harness_cache_dir=harness_cache_dir,
|
|
1283
|
+
).exit_code
|
|
1284
|
+
root = (project_root or Path.cwd()).resolve()
|
|
1285
|
+
prepared = _prepare_test(python, database, root)
|
|
1286
|
+
if prepared is None:
|
|
1287
|
+
return OPERATIONAL_ERROR
|
|
1288
|
+
selected, database_path = prepared
|
|
1289
|
+
if baseline is not None and not baseline_exists(
|
|
1290
|
+
database_path,
|
|
1291
|
+
baseline,
|
|
1292
|
+
python=selected,
|
|
1293
|
+
project_root=root,
|
|
1294
|
+
project_id=_project_id(root),
|
|
1295
|
+
):
|
|
1296
|
+
print(f"m3 test: baseline run was not found: {baseline}", file=sys.stderr)
|
|
1297
|
+
return OPERATIONAL_ERROR
|
|
1298
|
+
try:
|
|
1299
|
+
child_environment = _test_environment(env_file)
|
|
1300
|
+
except ProjectPythonError as exc:
|
|
1301
|
+
print(f"m3 test: {exc}", file=sys.stderr)
|
|
1302
|
+
return OPERATIONAL_ERROR
|
|
1303
|
+
return _run_pytest_process(
|
|
1304
|
+
selected,
|
|
1305
|
+
database_path,
|
|
1306
|
+
pytest_args,
|
|
1307
|
+
baseline=baseline,
|
|
1308
|
+
project_root=root,
|
|
1309
|
+
harnesses=harnesses,
|
|
1310
|
+
trials=trials,
|
|
1311
|
+
credential_env=credential_env,
|
|
1312
|
+
execution_timeout=execution_timeout,
|
|
1313
|
+
judge_max_requests=judge_max_requests,
|
|
1314
|
+
suite=suite,
|
|
1315
|
+
environment=child_environment,
|
|
1316
|
+
runtime=runtime,
|
|
1317
|
+
harness_cache_dir=harness_cache_dir,
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
__all__ = [
|
|
1322
|
+
"OPERATIONAL_ERROR",
|
|
1323
|
+
"ProjectPythonError",
|
|
1324
|
+
"StoredRun",
|
|
1325
|
+
"StoredRuns",
|
|
1326
|
+
"TestRunResult",
|
|
1327
|
+
"baseline_exists",
|
|
1328
|
+
"build_run_url",
|
|
1329
|
+
"find_new_runs",
|
|
1330
|
+
"list_stored_runs",
|
|
1331
|
+
"pytest_command",
|
|
1332
|
+
"resolve_project_python",
|
|
1333
|
+
"run_test",
|
|
1334
|
+
"run_test_with_runs",
|
|
1335
|
+
"validate_project_python",
|
|
1336
|
+
]
|