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/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
+ ]