Files
Nikita Fomichev 76afc65ba4 Praktika: prioritize errors over warnings when truncating build logs
When a command log exceeds `MAX_LINES_IN_INFO`, the report shows an
excerpt centered on the first line matching `: error:` or `: warning:`.
Build logs frequently contain many unrelated warnings (for example,
`-Wdeprecated-declarations` warnings from contrib libraries such as LLVM)
long before the actual compile error that stopped the build. Centering on
the first warning pushes the real error into the truncated tail, so the
report shows only irrelevant warnings and hides the cause of the failure.

Scan for the first `: error:` line first and only fall back to the first
`: warning:` when no error is present, so the actual compile error is
always visible in the truncated excerpt.
2026-07-02 13:02:40 +02:00

2126 lines
89 KiB
Python

import copy
import dataclasses
import datetime
import errno
import io
import json
import os
import random
import sys
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from ._environment import _Environment
from .event import Event
from .s3 import S3
from .settings import Settings
from .usage import ComputeUsage, StorageUsage
from .utils import ContextManager, MetaClasses, Shell, Utils
from .info import Info
class _Colors:
"""ANSI color codes for terminal output"""
RESET = "\033[0m"
BOLD = "\033[1m"
GREEN = "\033[92m"
RED = "\033[91m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
@dataclasses.dataclass
class Result(MetaClasses.Serializable):
"""
Represents the outcome of a workflow/job/task or any operation, along with associated metadata.
This class supports nesting of results to represent tasks with sub-tasks, and includes
various attributes to track status, timing, files, and links.
Attributes:
name (str): The name of the task.
status (str): The current status of the task. Should be one of the values defined in the Status class.
start_time (Optional[float]): The start time of the task in Unix timestamp format. None if not started.
duration (Optional[float]): The duration of the task in seconds. None if not completed.
results (List[Result]): A list of sub-results representing nested tasks.
files (List[str]): A list of file paths or names related to the result.
links (List[str]): A list of URLs related to the result (e.g., links to reports or resources).
info (str): Additional information about the result. Free-form text.
Inner Class:
Status: Defines possible statuses for the result.
"""
class Status:
# Outcome statuses (used for both job-level and sub-result/test-level results)
OK = "OK"
FAIL = "FAIL"
SKIPPED = "SKIPPED"
ERROR = "ERROR"
UNKNOWN = "UNKNOWN"
XFAIL = "XFAIL" # expected failure: test failed as expected, not a problem
XPASS = "XPASS" # unexpected pass: test was expected to fail but passed
# Lifecycle statuses (used for job-level and workflow-level results)
PENDING = "PENDING"
RUNNING = "RUNNING"
DROPPED = "DROPPED"
class GHStatus:
"""GitHub commit status API values — the only four strings GH accepts."""
PENDING = "pending"
SUCCESS = "success"
FAILURE = "failure"
ERROR = "error"
class Label:
OK_ON_RETRY = "retry_ok"
FAILED_ON_RETRY = "retry_failed"
BLOCKER = "blocker"
ISSUE = "issue"
INFRA = "infra"
XFAIL = "xfail"
CIDB = "cidb"
SETTING_VALUE = "setting"
FLAKY = "flaky"
REPRODUCIBLE = "reproducible"
LOG_CHECK = "log_check"
# Default hints rendered as a hover tooltip in json.html.
# Looked up automatically when set_label is called without an explicit hint.
LABEL_HINTS = {
Label.OK_ON_RETRY: "Test failed initially but passed on retry",
Label.FAILED_ON_RETRY: "Test failed both on the first run and on retry",
Label.BLOCKER: "Blocks the merge regardless if a known issue is matched (sanitizer/fatal in server logs, etc.)",
Label.ISSUE: "A known open GitHub issue matches this failure",
Label.INFRA: "Infrastructure error",
Label.XFAIL: "Expected to fail (bugfix validation inverts the status)",
Label.CIDB: "Failure history for this test in the CI database",
Label.SETTING_VALUE: "Failure caused by a specific randomized setting value",
Label.FLAKY: "Failure is reproducible in less than 100% of reruns",
Label.REPRODUCIBLE: "Failure is reproducible in 100% of reruns",
Label.LOG_CHECK: "Server-log / runner health check, not a test case (excluded from bugfix-validation inversion)",
}
name: str
status: str
start_time: Optional[float] = None
duration: Optional[float] = None
results: List["Result"] = dataclasses.field(default_factory=list)
files: List[Union[str, Path]] = dataclasses.field(default_factory=list)
assets: List[Union[str, Path]] = dataclasses.field(default_factory=list)
links: List[str] = dataclasses.field(default_factory=list)
info: str = ""
ext: Dict[str, Any] = dataclasses.field(default_factory=dict)
@staticmethod
def create_from(
name="",
results: List["Result"] = None,
stopwatch: Utils.Stopwatch = None,
status="",
files=None,
assets=None,
info: Union[List[str], str] = "",
with_info_from_results=False,
links=None,
labels=None,
) -> "Result":
if isinstance(status, bool):
status = Result.Status.OK if status else Result.Status.FAIL
if not results and not status:
print(
"WARNING: No results and no status provided - setting status to error"
)
status = Result.Status.ERROR
if not name:
name = _Environment.get().JOB_NAME
start_time = None
duration = None
if not stopwatch:
try:
preresult = Result.from_fs(name=name)
start_time = preresult.start_time
duration = datetime.datetime.now().timestamp() - preresult.start_time
except Exception:
print(
f"WARNING: Failed to get start time for [{name}] - start time and duration won't be set"
)
else:
start_time = stopwatch.start_time
duration = stopwatch.duration
result_status = status or Result.Status.OK
infos = []
if info:
if isinstance(info, str):
infos += [info]
else:
infos += info
if results and not status:
for result in results:
if result.status in (
Result.Status.OK,
Result.Status.SKIPPED,
Result.Status.XFAIL,
):
continue
elif result.status in (
Result.Status.ERROR,
):
result_status = Result.Status.ERROR
break
elif result.status in (
Result.Status.FAIL,
Result.Status.UNKNOWN,
Result.Status.XPASS,
):
result_status = Result.Status.FAIL
else:
Utils.raise_with_error(
f"Unexpected result status [{result.status}] for [{result.name}]"
)
if results and with_info_from_results:
for result in results:
if result.info:
infos.append(f"{result.name}: {result.info}")
result = Result(
name=name,
status=result_status,
start_time=start_time,
duration=duration,
info="\n".join(infos) if infos else "",
results=results or [],
assets=assets or [],
files=files or [],
links=links or [],
)
if isinstance(labels, str):
labels = [labels]
for label in labels or []:
result.set_label(label)
return result
@staticmethod
def get():
return Result.from_fs(_Environment.get().JOB_NAME)
@staticmethod
def get_workflow_result():
"""
Returns the latest workflow result, if available on fs
:return:
"""
return Result.from_fs(_Environment.get().WORKFLOW_NAME)
def is_completed(self):
return self.status not in (Result.Status.PENDING, Result.Status.RUNNING)
def is_skipped(self):
return self.status in (Result.Status.SKIPPED,)
def is_dropped(self):
return self.status in (Result.Status.DROPPED,)
def is_running(self):
return self.status in (Result.Status.RUNNING,)
def is_pending(self):
return self.status in (Result.Status.PENDING,)
def is_ok(self):
return self.status in (
Result.Status.OK,
Result.Status.SKIPPED,
Result.Status.XFAIL,
)
def is_success(self):
return self.status in (Result.Status.OK, Result.Status.XFAIL)
def is_failure(self):
return self.status in (Result.Status.FAIL, Result.Status.XPASS)
def is_error(self):
return self.status in (Result.Status.ERROR,)
def _dump_if_persisted(self) -> "Result":
"""Dump only if a result file already exists on disk.
Setters use this so that job-level results (already dumped by `complete_job`
or `copy_result_to_s3`) are kept up-to-date, while sub-results (tasks) never
create their own files — avoiding `OSError: File name too long` when a result
name is derived from a long error message.
"""
try:
exists = Path(self.file_name()).is_file()
except OSError as e:
if e.errno == errno.ENAMETOOLONG:
return self
raise
if exists:
self.dump()
return self
def set_status(self, status) -> "Result":
self.status = status
self._dump_if_persisted()
return self
def set_success(self) -> "Result":
return self.set_status(Result.Status.OK)
def set_failed(self) -> "Result":
return self.set_status(Result.Status.FAIL)
def set_error(self) -> "Result":
return self.set_status(Result.Status.ERROR)
def set_results(self, results: List["Result"]) -> "Result":
self.results = results
self._dump_if_persisted()
return self
def set_files(self, files, strict=True) -> "Result":
if isinstance(files, (str, Path)):
files = [files]
if strict:
for file in files:
assert Path(
file
).is_file(), f"Not valid file [{file}] from file list [{files}]"
if not self.files:
self.files = []
for file in self.files:
if file in files:
print(
f"WARNING: File [{file}] is already present in Result [{self.name}] - skip"
)
files.remove(file)
self.files += files
self._dump_if_persisted()
return self
def set_on_error_hook(self, hook: str) -> "Result":
"""
Sets a bash script to execute when the job encounters a critical error.
This hook runs on job-level failures such as:
- Hard timeout exceeded
- Job killed or terminated by the system
- Infrastructure failures
The hook script should handle cleanup, log collection, or any other
recovery actions needed before the job terminates.
Args:
hook: Bash script/commands to execute on error
Returns:
Self for method chaining
"""
self.ext["on_error_hook"] = hook
return self
def get_on_error_hook(self) -> str:
return self.ext.get("on_error_hook", "")
def set_info(self, info: str) -> "Result":
if self.info:
self.info += "\n"
self.info += info
self._dump_if_persisted()
return self
def add_warning(self, message: str) -> "Result":
"""
Add a warning message to this result only.
The message is stored in ``self.ext["warnings"]`` as
``{"message": str, "from": str}`` and rendered as a notification panel
on the report page for this specific result (workflow, job, sub-task,
or test). It is **not** propagated to parent or child results — use
``Info.add_workflow_warning`` when the message should appear on the job
level and be propagated to the top (workflow) level.
"""
self.ext.setdefault("warnings", []).append(
{"message": message, "from": self.name}
)
self._dump_if_persisted()
return self
def add_error(self, message: str) -> "Result":
"""
Add an error message to this result only.
See ``add_warning`` for propagation semantics.
"""
self.ext.setdefault("errors", []).append(
{"message": message, "from": self.name}
)
self._dump_if_persisted()
return self
def add_note(self, message: str) -> "Result":
"""
Add a note message to this result only.
See ``add_warning`` for propagation semantics.
"""
self.ext.setdefault("notes", []).append(
{"message": message, "from": self.name}
)
self._dump_if_persisted()
return self
def set_link(self, link) -> "Result":
self.links.append(link)
self._dump_if_persisted()
return self
def _add_job_summary_to_info(self):
if not self.info:
total = 0
fail_cnt = 0
for r in self.results:
if not r.is_ok():
fail_cnt += 1
total += 1
self.set_info(f"Failures: {fail_cnt}/{total}")
return self
@classmethod
def file_name_static(cls, name):
return f"{Settings.TEMP_DIR}/result_{Utils.normalize_string(name)}.json"
@classmethod
def from_dict(cls, obj: Dict[str, Any]) -> "Result":
sub_results = []
for result_dict in obj.get("results", []):
sub_res = cls.from_dict(result_dict)
sub_results.append(sub_res)
obj["results"] = sub_results
return Result(**obj)
def update_duration(self):
if self.duration:
return self
if self.start_time:
self.duration = datetime.datetime.now().timestamp() - self.start_time
else:
print(
f"NOTE: start_time is not set for job [{self.name}] Result - do not update duration"
)
return self
def set_timing(self, stopwatch: Utils.Stopwatch):
self.start_time = stopwatch.start_time
self.duration = stopwatch.duration
return self
@staticmethod
def _label_name(entry):
"""Return the name of a label entry (handles legacy string entries)."""
return entry if isinstance(entry, str) else entry.get("name")
_UNSET = object()
def set_label(self, label, link=_UNSET, hint=_UNSET):
"""Add or update a label.
Each label is stored as a dict {"name": str, "link"?: str, "hint"?: str}.
- link: optional URL — clicking the label opens it in a new tab.
- hint: optional tooltip text shown on hover. If omitted on a new label,
falls back to Result.LABEL_HINTS[label] when registered. On an existing
label, omitting link/hint preserves the current value.
"""
assert isinstance(label, str), (
f"label must be a string, got {type(label).__name__}"
)
labels = self.ext.setdefault("labels", [])
for i, existing in enumerate(labels):
if self._label_name(existing) == label:
merged = {"name": label}
if isinstance(existing, dict):
for k in ("link", "hint"):
if k in existing:
merged[k] = existing[k]
else:
# Legacy string entry — backfill default hint when migrating to dict.
default = self.LABEL_HINTS.get(label)
if default:
merged["hint"] = default
if link is not self._UNSET:
if link:
merged["link"] = link
else:
merged.pop("link", None)
if hint is not self._UNSET:
if hint:
merged["hint"] = hint
else:
merged.pop("hint", None)
labels[i] = merged
return self
entry = {"name": label}
if link is not self._UNSET and link:
entry["link"] = link
resolved_hint = hint if hint is not self._UNSET else self.LABEL_HINTS.get(label)
if resolved_hint:
entry["hint"] = resolved_hint
labels.append(entry)
return self
def remove_label(self, label):
if not self.ext.get("labels", None):
return self
self.ext["labels"] = [
l for l in self.ext["labels"] if self._label_name(l) != label
]
return self
def get_labels(self):
"""Return list of label names."""
return [self._label_name(l) for l in self.ext.get("labels", [])]
def has_label(self, label):
if label in self.get_labels():
return True
# Legacy fallback for results stored before the label/hlabel unification.
return label in [x[0] for x in self.ext.get("hlabels", []) if x]
def get_label_link(self, label):
for l in self.ext.get("labels", []):
if isinstance(l, dict) and l.get("name") == label:
return l.get("link")
# Legacy fallback for results stored before the label/hlabel unification.
for h in self.ext.get("hlabels", []):
if isinstance(h, (list, tuple)) and len(h) >= 2 and h[0] == label:
return h[1]
return None
def get_label_hint(self, label):
for l in self.ext.get("labels", []):
if isinstance(l, dict) and l.get("name") == label:
return l.get("hint")
return None
def set_comment(self, comment):
self.ext["comment"] = comment
@classmethod
def from_pytest_run(
cls,
command,
cwd=None,
name="Tests",
env=None,
pytest_report_file=None,
pytest_logfile=None,
logfile=None,
timeout=None,
):
"""
Runs a pytest command, captures results in jsonl format, and creates a Result object.
Args:
command (str): The pytest command to run (without 'pytest' itself)
cwd (str, optional): Working directory to run the command in
name (str, optional): Name for the root Result object
env (dict, optional): Environment variables for the pytest command
pytest_report_file (str, optional): Path to write the pytest jsonl report
logfile (str, optional): Path to write pytest output logs
timeout (int, optional): Hard timeout in seconds to kill the pytest process
Returns:
Result: A Result object with test cases as sub-Results
"""
sw = Utils.Stopwatch()
files = []
if pytest_report_file:
files.append(pytest_report_file)
else:
pytest_report_file = ResultTranslator.PYTEST_RESULT_FILE
with ContextManager.cd(cwd):
# Construct the full pytest command with jsonl report
full_command = f"pytest {command} --report-log={pytest_report_file}"
if pytest_logfile:
full_command += f" --log-file={pytest_logfile}"
files.append(pytest_logfile)
if logfile:
files.append(logfile)
# Apply environment
for key, value in (env or {}).items():
print(f"Setting environment variable {key} to {value}")
os.environ[key] = value
if name is None:
name = f"pytest_{command}"
# Run pytest
Shell.run(full_command, log_file=logfile, timeout=timeout)
test_result = ResultTranslator.from_pytest_jsonl(
pytest_report_file=pytest_report_file
)
return Result.create_from(
name=name,
results=test_result.results,
status=test_result.status,
stopwatch=sw,
info=test_result.info,
files=files,
)
@classmethod
def _filter_out_ok_results(cls, result_obj):
if not result_obj.results:
return result_obj
filtered = []
for r in result_obj.results:
if not r.is_ok():
filtered.append(cls._filter_out_ok_results(r))
if len(filtered) == len(result_obj.results):
return result_obj # No filtering needed
result_copy = copy.deepcopy(result_obj)
result_copy.results = filtered
return result_copy
@classmethod
def _flat_failed_leaves(cls, result_obj, path=None):
"""
Recursively flattens the result tree, returning a list of all failed leaf Result objects.
A leaf is a Result with no sub-results or with only ok sub-results.
Also tracks the path to each result and adds it to result.ext['path'] as a list of names.
"""
if path is None:
path = [result_obj.name]
else:
path = path + [result_obj.name]
# If this result is OK, skip it
if result_obj.is_ok():
return []
# Otherwise, collect failed leaves from children
leaves = []
for r in result_obj.results:
if r.is_ok():
continue
elif not r.results:
# This is a leaf - add the path to its ext
if not hasattr(r, "ext") or r.ext is None:
r.ext = {}
r.ext["result_tree_path"] = path + [
r.name
] # store hierarchical path to the leaf so that report can build a navigation link to it
leaves.append(r)
else:
# Recursively process children with updated path
leaves.extend(cls._flat_failed_leaves(r, path=path))
return leaves
def to_failed_results_with_flat_leaves(self):
"""
Creates a minimal result tree containing only failed jobs with their failed leaf results flattened.
Returns a two-level structure: top-level failed jobs -> flat list of their failed leaf results.
"""
result = copy.deepcopy(self)
failed_results = []
for r in result.results:
if not r.is_ok():
r.results = self._flat_failed_leaves(r)
failed_results.append(r)
result.results = failed_results
return result
def update_sub_result(self, result: "Result", drop_nested_results=False):
assert self.results, "BUG?"
for i, result_ in enumerate(self.results):
if result_.name == result.name:
if result_.is_skipped() and result.is_dropped():
# job was skipped in workflow configuration by a user' hook
print(
f"NOTE: Job [{result.name}] has completed status [{result_.status}] - do not switch status to [{result.status}]"
)
continue
if drop_nested_results:
# self.results[i] = self._filter_out_ok_results(result)
self.results[i] = copy.deepcopy(result)
self.results[i].results = self._flat_failed_leaves(
result, path=[self.name]
)
else:
self.results[i] = result
self._update_status()
return self
def extend_sub_results(self, results: List["Result"]):
assert isinstance(results, list) and len(results) > 0, "BUG?"
self.results += results
self._update_status()
return self
def _update_status(self):
was_pending = False
was_running = False
if self.status == self.Status.PENDING:
was_pending = True
if self.status == self.Status.RUNNING:
was_running = True
has_pending, has_running, has_failed = False, False, False
for result_ in self.results:
if result_.status in (self.Status.RUNNING,):
has_running = True
if result_.status in (self.Status.PENDING,):
has_pending = True
if result_.status in (
self.Status.ERROR,
self.Status.DROPPED,
self.Status.FAIL,
self.Status.UNKNOWN,
self.Status.XPASS,
):
has_failed = True
if has_running:
self.status = self.Status.RUNNING
elif has_pending:
self.status = self.Status.PENDING
elif has_failed:
self.status = self.Status.FAIL
else:
self.status = self.Status.OK
if (was_pending or was_running) and self.status not in (
self.Status.PENDING,
self.Status.RUNNING,
):
print("Pipeline finished")
self.update_duration()
def add_ext_key_value(self, key, value):
self.ext[key] = value
return self
@classmethod
def create_new(cls, name, status, links=None, info="", results=None):
return Result(
name=name,
status=status,
start_time=None,
duration=None,
results=results or [],
files=[],
links=links or [],
info=info,
)
@classmethod
def from_gtest_run(
cls,
unit_tests_path,
name="",
with_log=False,
command_launcher="",
gtest_filter="",
):
"""
Runs gtest and generates praktika Result from results
:param unit_tests_path: path to gtest binary
:param name: Should be set if executed as a job subtask with name @name.
If it's a job itself job.name will be taken as name by default
:param with_log: whether to log gtest output into separate file
:param command_prefix: prefix to add to gtest command
:param gtest_filter: gtest filter expression (passed as --gtest_filter)
:return: Result
"""
if not name:
name = Info().job_name
command = f"{unit_tests_path} --gtest_output='json:{ResultTranslator.GTEST_RESULT_FILE}'"
if gtest_filter:
command += f" --gtest_filter='{gtest_filter}'"
if command_launcher:
command = f"{command_launcher} {command}"
Shell.check(f"rm {ResultTranslator.GTEST_RESULT_FILE}")
result = Result.from_commands_run(
name=name,
command=[
f"chmod +x {unit_tests_path}",
command,
],
with_log=True,
)
binary_failed = not result.is_ok()
status, results, info = ResultTranslator.from_gtest()
result.set_status(status).set_results(results).set_info(info)
if binary_failed and result.is_ok():
# gtest cases all passed but binary run exited non-zero — e.g. sanitizer
# assertion triggered after the test body returned. This is a test failure,
# not an infrastructure error.
result.set_info("gtest binary run has non-zero exit code - see logs")
result.set_status(Result.Status.FAIL)
if result.is_error():
# gtest.json is missing — the binary was killed before it could write results
# (e.g. by a sanitizer, OOM, an uncaught exception, or a logical error). Note: gdb
# returns 0 even when the inferior exits with a non-zero code, so we can't rely on
# binary_failed here.
#
# gtest prints "[ RUN ] Suite.Test" before each test and a closing
# "[ OK ]"/"[ FAILED ]" line after it. When the binary dies mid-test, the last
# "[ RUN ]" line has no closing line and names the test that was running at the
# moment of the crash. Recover that name so the report shows the real test (instead of
# the gtest filter expression, e.g. "-FunctionsStress"), and scan only the lines after
# it for the crash message, so an unrelated error logged earlier by a test that
# finished normally (e.g. a deliberately-thrown-and-caught exception) is not mistaken
# for the cause.
# Covers sanitizer reports ("SUMMARY:"), ClickHouse logical errors, uncaught
# exceptions ("libc++abi:"/"terminate called") and fatal signals.
_ERROR_PREFIXES = (
"SUMMARY:",
"Logical error:",
"Code: ",
"Signal description:",
"libc++abi:",
"terminate called",
)
_ERROR_SUBSTRINGS = ("received signal SIG",)
crashed_test = ""
crash_info = ""
# Some gtests (e.g. the function property fuzzer, gtest_functions_stress) log the
# offending call as a runnable SQL query right before crashing, via
# logCurrentOperation: "(while executing: SELECT f(...);)". Surface it next to the
# error so the report shows a copy-pasteable repro instead of just the message.
repro_info = ""
_REPRO_MAX_LEN = 8192
if result.files:
log_content = Shell.get_output(f"cat {result.files[0]}", verbose=False)
log_lines = log_content.splitlines()
# Index of the last test that started running. Everything before it belongs to
# tests that already finished, so the crash output must come after it. If no
# "[ RUN ]" marker exists (the binary failed before any test ran), -1 makes
# the scan below cover the whole log.
last_run_idx = -1
for idx, line in enumerate(log_lines):
if not line.startswith("[ RUN"):
continue
bracket_end = line.find("]")
if bracket_end == -1:
continue
last_run_idx = idx
crashed_test = line[bracket_end + 1 :].strip()
for line in log_lines[last_run_idx + 1 :]:
if not crash_info and (
any(line.startswith(p) for p in _ERROR_PREFIXES)
or any(s in line for s in _ERROR_SUBSTRINGS)
):
crash_info = line.strip()
if not repro_info and line.startswith("(while ") and "SELECT " in line:
repro_info = line
if len(repro_info) > _REPRO_MAX_LEN:
repro_info = repro_info[:_REPRO_MAX_LEN] + " ... (truncated, see log)"
if crash_info and repro_info:
break
crash_info = "\n".join(p for p in (crash_info, repro_info) if p)
result.info = crash_info or info
# Synthesize a failed sub-result so the job summary is not empty. Prefer the test that
# was running when the binary died; fall back to the filter expression only when the
# log had no "[ RUN ]" marker at all.
crashed_test = crashed_test or gtest_filter.rstrip(".*") or "unknown"
result.set_results(
[Result(name=crashed_test, status=Result.Status.FAIL, info=crash_info or info)]
)
result.set_status(Result.Status.FAIL)
return result
@classmethod
def from_commands_run(
cls,
name,
command,
with_log=False,
with_info=False,
with_info_on_failure=True,
fail_fast=True,
workdir=None,
command_args=None,
command_kwargs=None,
retries=1,
retry_errors: Union[List[str], str] = "",
):
"""
Executes shell commands or Python callables, optionally logging output, and handles errors.
:param name: The name of the check.
:param command: A shell command (str) or Python callable, or list of them.
:param workdir: Optional working directory.
:param with_log: Whether to log output to a file.
:param with_info: Whether to fill in Result.info from command output.
:param with_info_on_failure: Whether to fill in Result.info from command output on failure only.
:param fail_fast: Whether to stop execution if one command fails.
:param command_args: Positional arguments for the callable command.
:param command_kwargs: Keyword arguments for the callable command.
:param retries: The number of times to retry the command if it fails.
:param retry_errors: The errors to retry on. Support for shell command(s) only.
:return: Result object with status and optional log file.
"""
# Stopwatch to track execution time
stop_watch_ = Utils.Stopwatch()
command_args = command_args or []
command_kwargs = command_kwargs or {}
# Set log file path if logging is enabled. Fall back to JOB_NAME so the log file
# gets a real basename when `name=""` (otherwise it ends up as just ".log").
if with_log or with_info or with_info_on_failure:
log_name = name or _Environment.get().JOB_NAME
log_file = f"{Utils.absolute_path(Settings.TEMP_DIR)}/{Utils.normalize_string(log_name)}.log"
else:
log_file = None
# Ensure the command is a list for consistent iteration
if not isinstance(command, list):
fail_fast = False
command = [command]
print(f"> Start execution for [{name}]")
res = True # Track success/failure status
info_lines = []
MAX_LINES_IN_INFO = 300
with ContextManager.cd(workdir):
for command_ in command:
if callable(command_):
assert (
retries == 1
), "FIXME: retry not supported for python callables"
# If command is a Python function, call it with provided arguments
if with_info or with_info_on_failure:
buffer = io.StringIO()
else:
buffer = "stdout"
try:
with Utils.Tee(stdout=buffer):
result = command_(*command_args, **command_kwargs)
except Exception as e:
result = False
info_lines.extend(
[
f"Command [{command_}] failed with exception [{e}]:",
*traceback.format_exc().splitlines(),
]
)
res = result if isinstance(result, bool) else not bool(result)
if (with_info_on_failure and not res) or with_info:
output = (
buffer.getvalue()
if isinstance(result, bool)
else str(result)
)
info_lines.extend(output.splitlines())
# Write callable output to log file for consistency with shell commands
if log_file and output:
with open(log_file, "a") as f:
f.write(
output if output.endswith("\n") else output + "\n"
)
else:
# Run shell command in a specified directory with logging and verbosity
exit_code = Shell.run(
command_,
verbose=True,
log_file=log_file,
retries=retries,
retry_errors=retry_errors,
)
if with_info or (with_info_on_failure and exit_code != 0):
log_output = Shell.get_output(f"cat {log_file}")
info_lines += log_output.splitlines()
res = exit_code == 0
# If fail_fast is enabled, stop on first failure
if not res and fail_fast:
print(f"Execution stopped due to failure in [{command_}]")
break
# Apply truncation if info_lines exceeds MAX_LINES_IN_INFO
truncated = False
if len(info_lines) > MAX_LINES_IN_INFO:
# For clang-tidy and similar builds, find the first error (or, if
# there is none, the first warning) and show context around it
# instead of just the last lines.
# Errors take priority over warnings: a build log often contains
# many unrelated warnings (e.g. deprecation warnings from contrib
# libraries) before the actual compile error that stopped the
# build. Centering the excerpt on the first warning would truncate
# away the real error, so scan for the first error first and only
# fall back to the first warning when no error is present.
first_error_idx = None
first_warning_idx = None
for idx, line in enumerate(info_lines):
# Match clang-tidy format: "file:line:col: error:" or "file:line:col: warning:"
if ": error:" in line:
first_error_idx = idx
break
if first_warning_idx is None and ": warning:" in line:
first_warning_idx = idx
if first_error_idx is None:
first_error_idx = first_warning_idx
if first_error_idx is not None:
# Show context around the first error (lines before and after)
CONTEXT_BEFORE = 50
CONTEXT_AFTER = MAX_LINES_IN_INFO - CONTEXT_BEFORE - 1
start_idx = max(0, first_error_idx - CONTEXT_BEFORE)
end_idx = min(len(info_lines), first_error_idx + CONTEXT_AFTER + 1)
truncated_before = start_idx
truncated_after = len(info_lines) - end_idx
new_info_lines = []
if truncated_before > 0:
new_info_lines.append(
f"~~~~~ truncated {truncated_before} lines at the beginning ~~~~~"
)
new_info_lines.extend(info_lines[start_idx:end_idx])
if truncated_after > 0:
new_info_lines.append(
f"~~~~~ truncated {truncated_after} lines at the end ~~~~~"
)
info_lines = new_info_lines
else:
# Default behavior: keep the last MAX_LINES_IN_INFO lines
truncated_count = len(info_lines) - MAX_LINES_IN_INFO
info_lines = [
f"~~~~~ truncated {truncated_count} lines ~~~~~"
] + info_lines[-MAX_LINES_IN_INFO:]
truncated = True
# Create and return the result object with status and log file (if any)
return Result.create_from(
name=name,
status=res,
stopwatch=stop_watch_,
info=info_lines,
files=([log_file] if (with_log or truncated) and log_file else None),
)
def do_not_block_pipeline_on_failure(self):
return self.ext.get("do_not_block_pipeline_on_failure", False)
def complete_job(
self,
with_job_summary_in_info=True,
do_not_block_pipeline_on_failure=False,
disable_attached_files_sorting=False,
):
if with_job_summary_in_info:
self._add_job_summary_to_info()
if do_not_block_pipeline_on_failure and not self.is_ok():
self.ext["do_not_block_pipeline_on_failure"] = True
if not disable_attached_files_sorting:
try:
# Normalize to string and sort by filename case-insensitively
self.files.sort(key=lambda f: Path(str(f)).name.lower())
except Exception as e:
print(f"WARNING: Failed to sort attached files: {e}")
self.dump()
print(self.to_stdout_formatted())
if not self.is_ok():
sys.exit(1)
else:
sys.exit(0)
def get_info_truncated(
self,
max_info_lines_cnt=100,
truncate_from_top=True,
max_line_length=0,
):
"""
Get truncated info string with line count and line length limits applied.
Args:
max_info_lines_cnt: Maximum number of info lines to include
truncate_from_top: If True, truncate from the top; if False, truncate from the bottom
max_line_length: Maximum length of each line (0 means no limit)
Returns:
Truncated info string
"""
info_lines = self.info.splitlines()
# Truncate info lines if too many
if len(info_lines) > max_info_lines_cnt:
truncated_count = len(info_lines) - max_info_lines_cnt
if truncate_from_top:
info_lines = [
f"~~~~~ truncated {truncated_count} lines ~~~~~"
] + info_lines[-max_info_lines_cnt:]
else:
info_lines = info_lines[:max_info_lines_cnt] + [
f"~~~~~ truncated {truncated_count} lines ~~~~~"
]
# Truncate individual lines if too long
if max_line_length > 0:
info_lines = [
line[:max_line_length] + "..." if len(line) > max_line_length else line
for line in info_lines
]
return "\n".join(info_lines)
def to_stdout_formatted(
self,
indent="",
output="",
max_info_lines_cnt=100,
truncate_from_top=True,
max_line_length=0,
):
"""
Format the result and its sub-results as a human-readable string for stdout output.
Args:
indent: Current indentation level (used for nested results)
output: Accumulated output string (used for recursive calls)
max_info_lines_cnt: Maximum number of info lines to display
truncate_from_top: If True, truncate from the top; if False, truncate from the bottom
max_line_length: Maximum length of each line (0 means no limit)
Returns:
Formatted string representation of the result
"""
add_frame = not output
sub_indent = indent + " "
# Check if colors should be used: local run + TTY + terminal supports colors
use_colors = (
Info().is_local_run
and sys.stdout.isatty()
and os.environ.get("TERM", "").lower() != "dumb"
)
# Define color variables once to avoid repetition
status_color = ""
frame_color = ""
reset_color = ""
if use_colors:
reset_color = _Colors.RESET
if self.is_success():
status_color = _Colors.GREEN + _Colors.BOLD
frame_color = _Colors.GREEN
elif self.is_failure() or self.is_error():
status_color = _Colors.RED + _Colors.BOLD
frame_color = _Colors.RED
else:
status_color = _Colors.YELLOW + _Colors.BOLD
frame_color = _Colors.YELLOW
if add_frame:
output = f"{indent}{frame_color}{'+'*80}{reset_color}\n"
if add_frame or not self.is_ok():
# Capitalize status and only show name if it's not empty
status_text = str(self.status).capitalize()
name_text = f" [{self.name}]" if self.name else ""
output += f"{indent}{status_color}{status_text}{reset_color}{name_text}\n"
truncated_info = self.get_info_truncated(
max_info_lines_cnt=max_info_lines_cnt,
truncate_from_top=truncate_from_top,
max_line_length=max_line_length,
)
for line in truncated_info.splitlines():
output += f"{sub_indent}| {line}\n"
# Recursively format sub-results if this result is not ok
if not self.is_ok():
for sub_result in self.results:
output = sub_result.to_stdout_formatted(
indent=sub_indent,
output=output,
max_info_lines_cnt=max_info_lines_cnt,
truncate_from_top=truncate_from_top,
max_line_length=max_line_length,
)
if add_frame:
output += f"{indent}{frame_color}{'+'*80}{reset_color}\n"
return output
def get_sub_result_by_name(self, name, recursive=False) -> Optional["Result"]:
if not name:
return self
for r in self.results:
if r.name == name:
return r
if recursive:
for r in self.results:
res = r.get_sub_result_by_name(name, recursive=True)
if res:
return res
return None
def sort(self, sub_result_name="", failed_first=True):
if not self.results:
return self
sub_result_to_sort = self.get_sub_result_by_name(sub_result_name)
if failed_first and sub_result_to_sort:
# Stable partition: move all not-ok results to beginning, preserve order within groups
not_ok_results = [r for r in sub_result_to_sort.results if not r.is_ok()]
ok_results = [r for r in sub_result_to_sort.results if r.is_ok()]
sub_result_to_sort.results = not_ok_results + ok_results
else:
raise RuntimeError("Not implemented")
return self
def to_event(self, info: "Info"):
result_dict = Result.to_dict(self)
def _prune_result_for_feed(result):
"""Strip result down to fields used by the Slack feed.
The feed only needs ``name`` and ``status`` from each sub-result,
plus ``report_url`` from the top-level ``ext``. Everything else
(links, storage_usage, files, assets, nested results, …) is dead
weight that bloats the per-user JSON on S3.
"""
if not isinstance(result, dict):
return
for key in ("info", "start_time", "duration", "files", "assets", "links"):
result.pop(key, None)
results = result.get("results")
if isinstance(results, list):
result["results"] = [
{"name": r.get("name", ""), "status": r.get("status", "")}
for r in results
if isinstance(r, dict)
]
# Keep only report_url from ext
ext = result.get("ext")
if isinstance(ext, dict):
report_url = ext.get("report_url", "")
result["ext"] = {"report_url": report_url} if report_url else {}
_prune_result_for_feed(result_dict)
return Event(
type=Event.Type.COMPLETED if self.is_completed() else Event.Type.RUNNING,
timestamp=int(time.time()),
sha=info.sha,
ci_status=self.status,
result=result_dict,
ext={
"pr_number": info.pr_number,
"pr_title": info.pr_title,
"branch": info.git_branch,
"commit_message": info.commit_message,
"linked_pr_number": info.linked_pr_number or 0,
"parent_pr_number": info.get_kv_data("parent_pr_number") or 0,
"repo_name": info.repo_name,
"report_url": info.get_job_report_url(latest=False),
"change_url": info.change_url,
"workflow_name": info.workflow_name,
"base_branch": info.base_branch,
"run_id": info.run_id,
"run_url": info.run_url,
"commit_authors": info.commit_authors,
"is_cancelled": self.ext.get("is_cancelled", False),
},
)
class ResultInfo:
SETUP_ENV_JOB_FAILED = (
"Failed to set up job env, it is praktika bug or misconfiguration"
)
PRE_JOB_FAILED = (
"Failed to do a job pre-run step, it is praktika bug or misconfiguration"
)
KILLED = "Job killed or terminated, no Result provided"
NOT_FOUND_IMPOSSIBLE = (
"No Result file (bug, or job misbehaviour, must not ever happen)"
)
DROPPED_DUE_TO_PREVIOUS_FAILURE = "Dropped due to previous failure"
TIMEOUT = "Timeout"
GH_STATUS_ERROR = "Failed to set GH commit status"
OPEN_ISSUES_CHECK_ERROR = "Failed to check open issues"
NOT_FINALIZED = (
"Job failed to produce Result due to a script error or CI runner issue"
)
S3_ERROR = "S3 call failure"
class _ResultS3:
# Map the ``kind`` field used in ``_Environment.REPORT_MESSAGES`` to the
# ``ext`` bucket rendered by ``json.html``. Unknown kinds fall into notes.
_REPORT_MESSAGE_KIND_TO_EXT_KEY = {
"warning": "warnings",
"error": "errors",
"note": "notes",
}
@classmethod
def append_report_messages(cls, result, messages):
"""
Append ``{"message", "kind", "from"}`` dicts from
``_Environment.REPORT_MESSAGES`` into ``result.ext`` under the
matching ``warnings``/``errors``/``notes`` bucket.
"""
for msg in messages:
key = cls._REPORT_MESSAGE_KIND_TO_EXT_KEY.get(msg.get("kind"), "notes")
result.ext.setdefault(key, []).append(
{"message": msg["message"], "from": msg["from"]}
)
@classmethod
def copy_result_to_s3(cls, result, clean=False):
result.dump()
env = _Environment.get()
result_file_path = result.file_name()
s3_path = f"{Settings.S3_REPORT_BUCKET}/{env.get_s3_prefix()}/{Path(result_file_path).name}"
if clean:
S3.delete(s3_path)
# gzip is supported by most browsers
archive_file = Utils.compress_gz(result_file_path)
if archive_file:
assert archive_file.endswith(".gz")
content_encoding = "gzip"
else:
content_encoding = ""
archive_file = result_file_path
url = S3.copy_file_to_s3(
s3_path=s3_path,
local_path=archive_file,
text=True,
content_encoding=content_encoding,
with_rename=True,
)
return url
@classmethod
def copy_result_from_s3(cls, local_path):
env = _Environment.get()
file_name = Path(local_path).name
s3_path = f"{Settings.S3_REPORT_BUCKET}/{env.get_s3_prefix()}/{file_name}"
S3.copy_file_from_s3(s3_path=s3_path, local_path=local_path)
@classmethod
def copy_result_from_s3_with_version(cls, local_path):
env = _Environment.get()
file_name = Path(local_path).name
s3_path = f"{Settings.S3_REPORT_BUCKET}/{env.get_s3_prefix()}"
s3_file = f"{s3_path}/{file_name}"
return S3.copy_file_from_s3_with_version(s3_path=s3_file, local_path=local_path)
@classmethod
def copy_result_to_s3_with_version(cls, result, version, no_strict=False):
result.dump()
filename = Path(result.file_name()).name
env = _Environment.get()
s3_path = f"{Settings.S3_REPORT_BUCKET}/{env.get_s3_prefix()}/"
s3_file = f"{s3_path}{filename}"
return S3.copy_file_to_s3_with_version(
s3_path=s3_file,
local_path=result.file_name(),
version=version,
text=True,
no_strict=no_strict,
)
@classmethod
def upload_result_files_to_s3(
cls, result: Result, s3_subprefix="", _uploaded_file_link=None
):
parts = [
s3_subprefix.strip("/"),
Utils.normalize_string(result.name).strip("/"),
]
s3_subprefix = "/".join([p for p in parts if p])
if not _uploaded_file_link:
_uploaded_file_link = {}
# Deduplicate files by normalizing paths to absolute strings
unique_files = {}
for file in result.files:
# Convert to Path and resolve to absolute path
file_path = Path(file).resolve()
file_str = str(file_path)
if file_str not in unique_files:
unique_files[file_str] = file # Keep original file reference
for file_str, file in unique_files.items():
try:
if not Path(file).is_file():
print(
f"ERROR: Invalid file [{file}] in [{result.name}] - skip upload"
)
result.set_info(f"WARNING: File [{file}] was not found")
file_link = S3._upload_file_to_s3(file, upload_to_s3=False)
elif file in _uploaded_file_link:
# in case different sub results have the same file for upload
file_link = _uploaded_file_link[file]
else:
is_text = False
for text_file_suffix in Settings.TEXT_CONTENT_EXTENSIONS:
if file.endswith(text_file_suffix):
print(
f"File [{file}] matches Settings.TEXT_CONTENT_EXTENSIONS [{Settings.TEXT_CONTENT_EXTENSIONS}] - add text attribute for s3 object"
)
is_text = True
break
file_link = S3._upload_file_to_s3(
file,
upload_to_s3=True,
text=is_text,
s3_subprefix=s3_subprefix,
)
_uploaded_file_link[file] = file_link
result.links.append(file_link)
except Exception as e:
traceback.print_exc()
print(
f"ERROR: Failed to upload file [{file}] for result [{result.name}]"
)
result.set_info(f"ERROR: Failed to upload file [{file}]: {e}")
result.files = []
# Upload assets in parallel (preserving relative paths for HTML interlinking)
if result.assets:
asset_paths = [
Path(a).resolve() for a in result.assets if Path(a).is_file()
]
if asset_paths:
common_root = os.path.commonpath([p.parent for p in asset_paths])
env = _Environment.get()
base_s3_prefix = f"{Settings.S3_REPORT_BUCKET}/{env.get_s3_prefix()}/{s3_subprefix}".replace(
"//", "/"
)
print(
f"INFO: Uploading {len(asset_paths)} assets to {base_s3_prefix} in parallel"
)
with ThreadPoolExecutor(max_workers=50) as executor:
futures = {
executor.submit(
S3.upload_asset_streaming,
asset,
f"{base_s3_prefix}/{asset.relative_to(common_root)}",
): asset
for asset in asset_paths
}
for future, asset in futures.items():
try:
future.result()
except Exception as e:
print(f"ERROR: Failed to upload asset [{asset}]: {e}")
result.assets = []
if result.results:
for result_ in result.results:
cls.upload_result_files_to_s3(
result_,
s3_subprefix=s3_subprefix,
_uploaded_file_link=_uploaded_file_link,
)
return result
@classmethod
def update_workflow_results(
cls,
workflow_name,
new_sub_results=None,
storage_usage=None,
compute_usage=None,
report_messages=None,
clear_report_sources=None,
):
assert new_sub_results
attempt = 1
prev_status = ""
new_status = ""
MAX_ATTEMPTS = 50
while attempt < MAX_ATTEMPTS:
version = cls.copy_result_from_s3_with_version(
Result.file_name_static(workflow_name)
)
workflow_result = Result.from_fs(workflow_name)
prev_status = workflow_result.status
if new_sub_results:
if isinstance(new_sub_results, Result):
new_sub_results = [new_sub_results]
for result_ in new_sub_results:
workflow_result.update_sub_result(
result_, drop_nested_results=True
).dump()
# TODO: consider not accumulating these 2 for reruns:
if storage_usage:
workflow_storage_usage = StorageUsage.from_dict(
workflow_result.ext.get("storage_usage", {})
).merge_with(storage_usage)
workflow_result.ext["storage_usage"] = workflow_storage_usage
if compute_usage:
workflow_compute_usage = ComputeUsage.from_dict(
workflow_result.ext.get("compute_usage", {})
).merge_with(compute_usage)
workflow_result.ext["compute_usage"] = workflow_compute_usage
if clear_report_sources:
for key in cls._REPORT_MESSAGE_KIND_TO_EXT_KEY.values():
if key in workflow_result.ext:
workflow_result.ext[key] = [
e for e in workflow_result.ext[key]
if e.get("from") not in clear_report_sources
]
if report_messages:
cls.append_report_messages(workflow_result, report_messages)
new_status = workflow_result.status
if cls.copy_result_to_s3_with_version(
workflow_result,
version=version + 1,
no_strict=attempt < MAX_ATTEMPTS - 1,
):
break
print(f"Attempt [{attempt}] to upload workflow result failed")
attempt += 1
# random delay (0-2s) to reduce contention and minimize race conditions
# when multiple concurrent jobs attempt to update the workflow report
time.sleep(random.uniform(0, 2))
print(f"Workflow status changed: [{prev_status}] -> [{new_status}]")
if prev_status != new_status:
return new_status
else:
return None
class ResultTranslator:
GTEST_RESULT_FILE = Path("./ci/tmp/gtest.json").absolute()
PYTEST_RESULT_FILE = Path("./ci/tmp/pytest.jsonl").absolute()
@classmethod
def from_gtest(cls):
"""The json is described by the next proto3 scheme:
(It's wrong, but that's a copy/paste from
https://google.github.io/googletest/advanced.html#generating-a-json-report)
syntax = "proto3";
package googletest;
import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
message UnitTest {
int32 tests = 1;
int32 failures = 2;
int32 disabled = 3;
int32 errors = 4;
google.protobuf.Timestamp timestamp = 5;
google.protobuf.Duration time = 6;
string name = 7;
repeated TestCase testsuites = 8;
}
message TestCase {
string name = 1;
int32 tests = 2;
int32 failures = 3;
int32 disabled = 4;
int32 errors = 5;
google.protobuf.Duration time = 6;
repeated TestInfo testsuite = 7;
}
message TestInfo {
string name = 1;
string file = 6;
int32 line = 7;
enum Status {
RUN = 0;
NOTRUN = 1;
}
Status status = 2;
google.protobuf.Duration time = 3;
string classname = 4;
message Failure {
string failures = 1;
string type = 2;
}
repeated Failure failures = 5;
}"""
test_results = [] # type: List[Result]
if not Path(cls.GTEST_RESULT_FILE).exists():
print(f"ERROR: No test result file [{cls.GTEST_RESULT_FILE}]")
return (
Result.Status.ERROR,
test_results,
f"No test result file [{cls.GTEST_RESULT_FILE}]",
)
try:
with open(
cls.GTEST_RESULT_FILE, "r", encoding="utf-8", errors="ignore"
) as j:
report = json.load(j)
except Exception as e:
print(f"ERROR: failed to read json [{e}]")
return (
Result.Status.ERROR,
[
Result(
name="Parsing Error",
status=Result.Status.ERROR,
files=[cls.GTEST_RESULT_FILE],
info=str(e),
)
],
"ERROR: failed to read gtest json",
)
total_counter = report["tests"]
failed_counter = report["failures"]
error_counter = report["errors"]
description = ""
SEGFAULT = "Segmentation fault. "
SIGNAL = "Exit on signal. "
for suite in report["testsuites"]:
suite_name = suite["name"]
for test_case in suite["testsuite"]:
case_name = test_case["name"]
test_time = float(test_case["time"][:-1])
raw_logs = None
if "failures" in test_case:
raw_logs = ""
for failure in test_case["failures"]:
raw_logs += failure["failure"]
if (
"Segmentation fault" in raw_logs # type: ignore
and SEGFAULT not in description
):
description += SEGFAULT
if (
"received signal SIG" in raw_logs # type: ignore
and SIGNAL not in description
):
description += SIGNAL
if test_case["status"] == "NOTRUN":
test_status = Result.Status.SKIPPED
elif raw_logs is None:
test_status = Result.Status.OK
else:
test_status = Result.Status.FAIL
test_results.append(
Result(
f"{suite_name}.{case_name}",
test_status,
duration=test_time,
info=raw_logs,
)
)
check_status = Result.Status.OK
test_status = Result.Status.OK
tests_time = float(report["time"][:-1])
if failed_counter:
check_status = Result.Status.FAIL
test_status = Result.Status.FAIL
if error_counter:
check_status = Result.Status.ERROR
test_status = Result.Status.ERROR
test_results.append(Result(report["name"], test_status, duration=tests_time))
if not description:
description += (
f"fail: {failed_counter + error_counter}, "
f"passed: {total_counter - failed_counter - error_counter}"
)
return (
check_status,
test_results,
description,
)
@classmethod
def from_pytest_jsonl(cls, pytest_report_file, enable_capture_output_to_info=False):
"""
Parses a pytest jsonl report file and creates a hierarchical Result object.
Args:
pytest_report_file (str): Path to the pytest jsonl report file
enable_capture_output_to_info (bool): Whether to capture test output in Result.info
Returns:
List[Result]: A list of Result objects representing individual test cases
"""
name = "pytest"
sw = Utils.Stopwatch()
if not os.path.isfile(pytest_report_file):
print(f"ERROR: Pytest report file {pytest_report_file} not found")
return Result.create_from(
name=name,
status=Result.Status.ERROR,
info=f"Pytest report file {pytest_report_file} not found",
stopwatch=sw,
)
# Track test cases by their node_id, and also track failures by phase
test_results = {}
test_failures = {} # To track failures in each phase (setup/call/teardown)
session_exitstatus = None # Track overall session exit status
try:
with open(pytest_report_file, "r") as f:
for line in f:
try:
entry = json.loads(line.strip())
# Process SessionFinish to check exitstatus
if entry.get("$report_type") == "SessionFinish":
session_exitstatus = entry.get("exitstatus")
continue
# NEW: Handle collection-time reports (import errors, syntax errors, etc.)
if entry.get("$report_type") == "CollectReport":
node_id = entry.get("nodeid") or ""
outcome = entry.get("outcome")
# Only surface failed collection items to avoid noise
if outcome in ("failed", "error"):
# Build info from longrepr and optional sections
info_parts = []
longrepr = entry.get("longrepr")
if isinstance(longrepr, str) and longrepr:
info_parts.append(longrepr)
elif isinstance(longrepr, dict) and longrepr:
# Best-effort: mirror traceback builder from TestReport for dict shape
try:
lr_txt = ""
crash = (
longrepr.get("reprcrash")
if isinstance(longrepr, dict)
else None
)
if isinstance(crash, dict):
p = crash.get("path")
ln = crash.get("lineno")
msg = crash.get("message")
seg = []
if p is not None and ln is not None:
seg.append(f"File: {p}:{ln}")
if msg:
seg.append(str(msg))
if seg:
lr_txt += "\n".join(seg)
rt = (
longrepr.get("reprtraceback")
if isinstance(longrepr, dict)
else None
)
if isinstance(rt, dict) and "reprentries" in rt:
composed = []
for re_entry in rt.get("reprentries", []):
dd = re_entry.get("data", {})
fileloc = (
dd.get("reprfileloc", {})
if isinstance(dd, dict)
else {}
)
fpath = fileloc.get("path")
flineno = fileloc.get("lineno")
fmsg = fileloc.get("message")
header_parts = []
if (
fpath is not None
and flineno is not None
):
header_parts.append(
f"File: {fpath}:{flineno}"
)
if fmsg:
header_parts.append(str(fmsg))
if header_parts:
composed.append(
" - ".join(header_parts)
)
if isinstance(dd, dict) and dd.get(
"lines"
):
composed.extend(dd["lines"])
if composed:
if lr_txt:
lr_txt += "\n"
lr_txt += "\n".join(composed)
if lr_txt:
info_parts.append(lr_txt)
except Exception:
pass
if enable_capture_output_to_info:
# Sections (captured output) if any
sections = entry.get("sections", [])
try:
sec_chunks = []
for sec in sections:
if isinstance(sec, list) and len(sec) == 2:
title, content = sec
if content:
sec_chunks.append(
f"===== {title} =====\n{content}"
)
if sec_chunks:
info_parts.append("\n".join(sec_chunks))
except Exception:
pass
# Create a result for the module/node that failed to collect
test_results[node_id or "<collection>"] = Result(
name=node_id or "<collection>",
status=Result.Status.ERROR,
duration=None,
info="\n".join([p for p in info_parts if p]),
)
# Skip successful collection entries
continue
# Process based on event type
if entry.get("$report_type") in ("TestReport",):
node_id = entry.get("nodeid")
outcome = entry.get("outcome")
duration = entry.get("duration")
when = entry.get("when", "")
# Build a human-readable traceback string from longrepr
traceback_str = ""
if "longrepr" in entry:
data = entry["longrepr"]
# reprcrash: include file:line and message if present
if (
data
and isinstance(data, dict)
and "reprcrash" in data
):
crash = data.get("reprcrash", {})
path = crash.get("path")
lineno = crash.get("lineno")
message = crash.get("message")
parts = []
has_rt = isinstance(data.get("reprtraceback"), dict)
if not has_rt:
if path is not None and lineno is not None:
parts.append(f"File: {path}:{lineno}")
if message:
parts.append(str(message))
if parts:
traceback_str += "\n".join(parts)
# reprtraceback: collect lines
if (
data
and isinstance(data, dict)
and "reprtraceback" in data
):
rt = data.get("reprtraceback", {})
if isinstance(rt, dict) and "reprentries" in rt:
composed = []
for re_entry in rt.get("reprentries", []):
dd = re_entry.get("data", {})
# include per-frame file location for full stack context
fileloc = (
dd.get("reprfileloc", {})
if isinstance(dd, dict)
else {}
)
fpath = fileloc.get("path")
flineno = fileloc.get("lineno")
fmsg = fileloc.get("message")
header_parts = []
if (
fpath is not None
and flineno is not None
):
header_parts.append(
f"File: {fpath}:{flineno}"
)
if fmsg:
header_parts.append(str(fmsg))
if header_parts:
composed.append(
" - ".join(header_parts)
)
if isinstance(dd, dict) and dd.get("lines"):
composed.extend(dd["lines"])
if composed:
if traceback_str:
traceback_str += "\n"
traceback_str += "\n".join(composed)
# chain: fallback/additional entries (only if no reprtraceback)
elif (
data and isinstance(data, dict) and "chain" in data
):
try:
chain = data.get("chain", [])
for pair in chain:
# pair typically is [reprtraceback, reprcrash, context]
if not isinstance(pair, list):
continue
if len(pair) >= 1 and isinstance(
pair[0], dict
):
rt = pair[0]
if "reprentries" in rt:
for re_entry in rt.get(
"reprentries", []
):
dd = re_entry.get("data", {})
fileloc = (
dd.get("reprfileloc", {})
if isinstance(dd, dict)
else {}
)
fpath = fileloc.get("path")
flineno = fileloc.get("lineno")
fmsg = fileloc.get("message")
header_parts = []
if (
fpath is not None
and flineno is not None
):
header_parts.append(
f"File: {fpath}:{flineno}"
)
if fmsg:
header_parts.append(
str(fmsg)
)
if header_parts:
if traceback_str:
traceback_str += "\n"
traceback_str += " - ".join(
header_parts
)
if (
isinstance(dd, dict)
and "lines" in dd
and dd["lines"]
):
if traceback_str:
traceback_str += "\n"
traceback_str += "\n".join(
dd["lines"]
)
if len(pair) >= 2 and isinstance(
pair[1], dict
):
crash = pair[1]
p = crash.get("path")
ln = crash.get("lineno")
msg = crash.get("message")
seg = []
if p is not None and ln is not None:
seg.append(f"File: {p}:{ln}")
if msg:
seg.append(str(msg))
if seg:
if traceback_str:
traceback_str += "\n"
traceback_str += "\n".join(seg)
except Exception:
# Be resilient to unexpected shapes
pass
# In pytest 8.x, xfail outcomes are not reported via the
# "outcome" field directly. Instead they appear as:
# xfailed: outcome="skipped" + wasxfail field present
# xpassed: outcome="passed" + wasxfail field present
# Normalize those here so the mapping below handles both
# pytest 7.x ("xfailed"/"xpassed") and pytest 8.x.
if entry.get("wasxfail") is not None:
if outcome == "skipped":
outcome = "xfailed"
elif outcome == "passed":
outcome = "xpassed"
# Map pytest outcome to Result status
status = {
"passed": Result.Status.OK,
"failed": Result.Status.FAIL,
"skipped": Result.Status.SKIPPED,
"xfailed": Result.Status.XFAIL, # expected failure: OK
"xpassed": Result.Status.XPASS, # unexpected pass: fails job
"error": Result.Status.ERROR,
}.get(outcome, Result.Status.ERROR)
# Track failures by phase (XFAIL is not a failure)
if status in (
Result.Status.FAIL,
Result.Status.ERROR,
Result.Status.XPASS,
):
if node_id not in test_failures:
test_failures[node_id] = {}
test_failures[node_id][when] = status
# Include captured sections (stdout/stderr) for failures to help debugging
if (
outcome in ("failed", "error")
and entry.get("sections")
and enable_capture_output_to_info
):
try:
sec_chunks = []
for sec in entry.get("sections", []):
if isinstance(sec, list) and len(sec) == 2:
title, content = sec
if content:
sec_chunks.append(
f"===== {title} =====\n{content}"
)
if sec_chunks:
sec_text = "\n".join(sec_chunks)
if traceback_str:
traceback_str += "\n" + sec_text
else:
traceback_str = sec_text
except Exception:
pass
# Create or update test result
if node_id not in test_results:
test_result = Result(
name=node_id,
status=status,
duration=duration,
info=traceback_str,
)
test_results[node_id] = test_result
else:
# accumulate duration setup + call + teardown
test_results[node_id].duration += duration
# Always override with a failure, or keep existing failure
_failure_statuses = (
Result.Status.FAIL,
Result.Status.XPASS,
)
if (
status in _failure_statuses
or test_results[node_id].status in _failure_statuses
):
test_results[node_id].status = status
# Update info if we now have traceback
if traceback_str:
if not test_results[node_id].info:
test_results[node_id].info = traceback_str
elif (
traceback_str not in test_results[node_id].info
):
test_results[node_id].info += (
f"\n[{when}]\n" + traceback_str
)
# Only update with non-failure if there's no existing failure
elif test_results[node_id].status not in (
Result.Status.FAIL,
Result.Status.ERROR,
Result.Status.XPASS,
):
# For non-failures, prefer 'call' phase over others
if when == "call":
test_results[node_id].status = status
except json.JSONDecodeError as e:
print(f"Error decoding line in jsonl file: {e}")
traceback.print_exc()
continue
# Make a final pass to ensure any test with failures in any phase is marked as failed
for node_id, failures in test_failures.items():
if failures: # If there are any failures for this test
# Prioritize failures: setup > call > teardown
if "setup" in failures:
test_results[node_id].status = failures["setup"]
elif "call" in failures:
test_results[node_id].status = failures["call"]
elif "teardown" in failures:
test_results[node_id].status = failures["teardown"]
R = Result.create_from(name=name, results=list(test_results.values()), stopwatch=sw)
if session_exitstatus == 0:
# pytest exit code 0 means all tests passed or xfailed (from pytest's perspective).
# We additionally treat XPASS as a failure, so FAIL is also valid here.
assert R.status in (
Result.Status.OK,
Result.Status.FAIL,
), f"pytest session exit code 0 does not match autogenerated status [{R.status}]"
return R
if session_exitstatus == 1:
if R.status == Result.Status.OK:
print(
f"WARNING: Tests are all OK, but exit code is 1; timeout or other runner issue - reset overall status to [{Result.Status.ERROR}]"
)
R.status = Result.Status.ERROR
return R
R.status = Result.Status.ERROR
if session_exitstatus == 2:
R.info = "Test execution was interrupted"
elif session_exitstatus == 3:
R.info = "Internal error in pytest or a plugin"
elif session_exitstatus == 4:
R.info = "pytest command line usage error"
elif session_exitstatus == 5:
R.info = "No tests were collected"
else:
R.info = "Unknown error"
R.info += f" (exit status: {session_exitstatus})"
return R
except Exception as e:
print(f"Failed to parse pytest jsonl: {e}, {traceback.print_exc()}")
traceback.print_exc()
return Result.create_from(
name=name,
status=Result.Status.ERROR,
info=f"Failed to parse pytest jsonl: {e}, {traceback.print_exc()}",
stopwatch=sw,
)