141 lines
4.5 KiB
Python
141 lines
4.5 KiB
Python
import json
|
|
import locale
|
|
import re
|
|
import subprocess
|
|
import time
|
|
|
|
from .models import CommandResult, ResultCheckKind
|
|
|
|
DIAGNOSTIC_LIMIT = 8000
|
|
|
|
|
|
class ValidationFailure(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _decode(value):
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, str):
|
|
return value
|
|
for encoding in ("utf-8", locale.getpreferredencoding(False)):
|
|
try:
|
|
return value.decode(encoding)
|
|
except (UnicodeDecodeError, LookupError):
|
|
continue
|
|
return value.decode("utf-8", errors="replace")
|
|
|
|
|
|
def _check_result(check, result):
|
|
if check.kind is ResultCheckKind.CMAKE_COMPILER_IS_MSVC:
|
|
if check.path is None:
|
|
raise ValidationFailure("MSVC check is missing binaryDir")
|
|
files = sorted(check.path.glob("CMakeFiles/*/CMakeCXXCompiler.cmake"))
|
|
if not files:
|
|
raise ValidationFailure("CMake did not generate compiler metadata")
|
|
text = files[-1].read_text(encoding="utf-8", errors="replace")
|
|
if not re.search(
|
|
r'set\s*\(\s*CMAKE_CXX_COMPILER_ID\s+"MSVC"\s*\)', text
|
|
):
|
|
raise ValidationFailure("CMake selected a compiler other than MSVC")
|
|
elif check.kind is ResultCheckKind.CTEST_HAS_TESTS:
|
|
try:
|
|
payload = json.loads(result.stdout)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValidationFailure("CTest discovery did not return JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise ValidationFailure("CTest discovery JSON must be an object")
|
|
if not isinstance(payload.get("tests"), list) or not payload["tests"]:
|
|
raise ValidationFailure("CTest discovered no tests")
|
|
|
|
|
|
def _diagnostic(stdout, stderr):
|
|
diagnostic = (stdout + "\n" + stderr).rstrip()[-DIAGNOSTIC_LIMIT:]
|
|
return diagnostic.replace("\r\n", " | ").replace("\n", " | ")
|
|
|
|
|
|
def _command_context(argv, cwd):
|
|
return f"argv={list(argv)!r}; cwd={str(cwd)!r}"
|
|
|
|
|
|
def execute_plan(
|
|
plan,
|
|
root,
|
|
*,
|
|
env=None,
|
|
total_timeout_seconds=1800,
|
|
deadline=None,
|
|
run=subprocess.run,
|
|
clock=time.monotonic,
|
|
):
|
|
root = root.resolve()
|
|
if deadline is None:
|
|
deadline = clock() + total_timeout_seconds
|
|
results = []
|
|
|
|
for step in plan.steps:
|
|
cwd = step.command.cwd.resolve()
|
|
context = _command_context(step.command.argv, cwd)
|
|
try:
|
|
cwd.relative_to(root)
|
|
except ValueError as exc:
|
|
raise ValidationFailure(
|
|
f"{step.command.stage} working directory is outside the repository; "
|
|
f"{context}"
|
|
) from exc
|
|
|
|
remaining = deadline - clock()
|
|
if remaining <= 0:
|
|
raise ValidationFailure(
|
|
f"{step.command.stage} timed out before it could start; {context}"
|
|
)
|
|
|
|
timeout = min(step.command.timeout_seconds, remaining)
|
|
try:
|
|
completed = run(
|
|
list(step.command.argv),
|
|
cwd=cwd,
|
|
env=env,
|
|
capture_output=True,
|
|
shell=False,
|
|
timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
stdout = _decode(exc.output)
|
|
stderr = _decode(exc.stderr)
|
|
diagnostic = _diagnostic(stdout, stderr)
|
|
message = (
|
|
f"{step.command.stage} timed out after {timeout} seconds; {context}"
|
|
)
|
|
if diagnostic:
|
|
message = f"{message}; output tail: {diagnostic}"
|
|
raise ValidationFailure(message) from exc
|
|
|
|
stdout = _decode(completed.stdout)
|
|
stderr = _decode(completed.stderr)
|
|
result = CommandResult(step.command, completed.returncode, stdout, stderr)
|
|
if completed.returncode != 0:
|
|
diagnostic = _diagnostic(stdout, stderr)
|
|
message = (
|
|
f"{step.command.stage} failed with exit code {completed.returncode}; "
|
|
f"{context}"
|
|
)
|
|
if diagnostic:
|
|
message = f"{message}; output tail: {diagnostic}"
|
|
raise ValidationFailure(message)
|
|
|
|
try:
|
|
for check in step.checks:
|
|
_check_result(check, result)
|
|
except ValidationFailure as exc:
|
|
diagnostic = _diagnostic(stdout, stderr)
|
|
message = (
|
|
f"{step.command.stage} result check failed: {exc}; {context}"
|
|
)
|
|
if diagnostic:
|
|
message = f"{message}; output tail: {diagnostic}"
|
|
raise ValidationFailure(message) from exc
|
|
results.append(result)
|
|
|
|
return tuple(results)
|