Files
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

2482 lines
87 KiB
Python

#!/usr/bin/env python3
# Copyright Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
import argparse
import glob
import json
import logging
import os
import platform
import re
import resource
import shlex
import shutil
import signal
import subprocess
import sys
import sysconfig
import time
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, NoReturn, Optional, Set, Tuple, Union
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s: %(message)s"
)
logger = logging.getLogger(__name__)
# Status symbols used throughout.
STATUS_PASS = "[✓]"
STATUS_FAIL = "[X]"
STATUS_WARN = "[!]"
STATUS_FLAKY = "[~]"
# Test result categories.
TEST_CATEGORIES = [
"PASS",
"FAIL",
"ERROR",
"UNRESOLVED",
"TIMEOUT",
"UNTESTED",
"UNSUPPORTED",
"XFAIL",
"KFAIL",
"FLAKY", # Tests that failed initially but passed on retry.
]
# Default wall-clock timeout (seconds) for each `make check` invocation in
# --one-by-one mode when --one-by-one-test-timeout is not provided.
ONE_BY_ONE_DEFAULT_WALL_CLOCK_TIMEOUT = 3600
# Sanitizers we pre-configure on every run. If rocgdb wasn't built with the
# corresponding -fsanitize=..., the matching *_OPTIONS env var is silently
# ignored at runtime, so listing extras here is harmless.
SANITIZERS = ("asan", "tsan", "ubsan", "msan", "lsan", "hwasan")
# Patterns for parsing DejaGnu .sum lines.
RESULT_LINE_RE = re.compile(
r"^(PASS|FAIL|XFAIL|UNTESTED|UNSUPPORTED|KFAIL|UNRESOLVED): (.+)$"
)
TIMEOUT_RE = re.compile(r"\(timeout\)")
RUNNING_EXP_RE = re.compile(r"Running\s+(\S+\.exp)")
# Category display configuration: prefix symbol.
CATEGORY_DISPLAY = {
"PASS": STATUS_PASS,
"FAIL": STATUS_FAIL,
"ERROR": STATUS_FAIL,
"UNRESOLVED": STATUS_FAIL,
"TIMEOUT": STATUS_WARN,
"UNTESTED": STATUS_WARN,
"UNSUPPORTED": STATUS_WARN,
"XFAIL": STATUS_WARN,
"KFAIL": STATUS_WARN,
"FLAKY": STATUS_FLAKY,
}
def _log_error_and_exit(message: str, exit_code: int = 1) -> NoReturn:
"""
Log an error message and exit the program.
Args:
message: Error message to log.
exit_code: Exit code (default 1).
"""
logger.error(f"{STATUS_FAIL} Error: {message}")
sys.exit(exit_code)
def _run_command(
cmd: List[str],
cwd: Optional[Path] = None,
env: Optional[Dict[str, str]] = None,
capture_output: bool = False,
check: bool = True,
error_msg: Optional[str] = None,
timeout: Optional[int] = None,
kill_process_group: bool = False,
) -> subprocess.CompletedProcess:
"""
Run a command with consistent error handling.
Args:
cmd: Command and arguments to execute.
cwd: Working directory for command execution.
env: Environment variables for command.
capture_output: Whether to capture stdout/stderr.
check: Whether to raise on non-zero exit.
error_msg: Custom error message on failure.
timeout: Optional wall-clock timeout in seconds; raises
subprocess.TimeoutExpired if exceeded.
kill_process_group: Run the command in a new process group/session and,
on TimeoutExpired, SIGKILL the entire group so descendants
(e.g. runtest/expect/gdb spawned by `make`) are not orphaned.
Returns:
CompletedProcess result.
Exits:
Code 1 if check=True, command exits with non-zero status, and error_msg is provided.
Raises:
CalledProcessError if check=True, command exits with non-zero status, and error_msg is None.
TimeoutExpired if timeout is set and exceeded.
"""
stdout = subprocess.PIPE if capture_output else None
stderr = subprocess.PIPE if capture_output else None
try:
process = subprocess.Popen(
cmd,
cwd=str(cwd) if cwd else None,
env=env,
text=True,
stdout=stdout,
stderr=stderr,
start_new_session=kill_process_group,
)
except OSError as e:
if error_msg:
_log_error_and_exit(f"{error_msg}: {e}")
else:
_log_error_and_exit(f"Failed to execute {cmd[0]!r}: {e}")
try:
out, err = process.communicate(timeout=timeout)
except BaseException:
# Cover TimeoutExpired and any other escaping exception
# (KeyboardInterrupt, OSError mid-read, etc.) so the make /
# runtest / expect / gdb process tree is never orphaned. With a new
# session we can SIGKILL the whole group; otherwise just the child.
if kill_process_group:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
else:
process.kill()
try:
out, err = process.communicate()
except Exception:
out, err = None, None
raise
result = subprocess.CompletedProcess(cmd, process.returncode, out, err)
if check and process.returncode != 0:
exc = subprocess.CalledProcessError(process.returncode, cmd, out, err)
if error_msg:
_log_error_and_exit(f"{error_msg}: {exc}")
raise exc
return result
def _read_file_lines(file_path: Path, error_context: str = "") -> List[str]:
"""
Read file lines with error handling.
Args:
file_path: Path to file to read.
error_context: Context for error message.
Returns:
List of stripped lines from file.
"""
try:
with open(file_path, encoding="utf-8") as f:
return [line.strip() for line in f]
except OSError as e:
_log_error_and_exit(
f"Failed to read {file_path}: {e}"
f"{'. ' + error_context if error_context else ''}"
)
def _extract_test_file_from_name(test_name: str) -> str:
"""
Extract test file path from full test name.
Args:
test_name: Full test name (e.g., "gdb.rocm/foo.exp: test description").
Returns:
Test file path without description (e.g., "gdb.rocm/foo.exp").
"""
return test_name.split(": ", 1)[0]
def load_ignore_list_from_json(json_path: Path) -> Dict[str, List[str]]:
"""
Load test ignore list from JSON file.
Args:
json_path: Path to JSON file containing ignore list.
Returns:
Dictionary mapping compiler labels to lists of ignored test paths.
Returns empty dict if file doesn't exist.
Exits:
Code 1 if JSON file exists but is malformed.
"""
if not json_path.exists():
logger.warning(
f"Ignore list file not found: {json_path}. Using empty ignore list."
)
return {}
try:
with open(json_path, encoding="utf-8") as f:
data = json.load(f)
except OSError as e:
_log_error_and_exit(f"Failed to read {json_path}: {e}")
except json.JSONDecodeError as e:
_log_error_and_exit(f"Failed to parse JSON from {json_path}: {e}")
if not isinstance(data, dict):
_log_error_and_exit(
f"Invalid ignore list format in {json_path}: expected a JSON object"
)
# Validate structure: each key should map to a list of strings.
for key, value in data.items():
if not isinstance(value, list):
_log_error_and_exit(
f"Invalid ignore list format in {json_path}: "
f"key '{key}' should map to a list"
)
if not all(isinstance(item, str) for item in value):
_log_error_and_exit(
f"Invalid ignore list format in {json_path}: "
f"all items in list for key '{key}' should be strings"
)
return data
def _non_negative_int(value: str) -> int:
"""
Validate argument is a non-negative integer.
Args:
value: String value from command line.
Returns:
Non-negative integer value.
Raises:
argparse.ArgumentTypeError: If value is negative.
"""
ivalue = int(value)
if ivalue < 0:
raise argparse.ArgumentTypeError(f"{value} is not a non-negative integer")
return ivalue
def _timeout_value(value: str) -> int:
"""
Validate timeout is a positive integer (seconds).
Args:
value: String value from command line.
Returns:
Integer timeout value.
Raises:
argparse.ArgumentTypeError: If value is not a positive integer.
"""
ivalue = int(value)
if ivalue <= 0:
raise argparse.ArgumentTypeError(
f"timeout must be a positive integer (got {ivalue})"
)
return ivalue
def _positive_nonzero_int(value: str) -> int:
"""
Validate argument is a positive non-zero integer.
Args:
value: String value from command line.
Returns:
Positive non-zero integer value.
Raises:
argparse.ArgumentTypeError: If value is not positive and non-zero.
"""
ivalue = int(value)
if ivalue <= 0:
raise argparse.ArgumentTypeError(f"{value} is not a positive non-zero integer")
return ivalue
def parse_arguments() -> argparse.Namespace:
"""
Parse and validate command-line arguments for the ROCgdb test suite.
Returns:
Parsed arguments with validated paths, timeout, and retry settings.
Exits:
Code 1 if arguments are invalid or paths don't exist.
"""
parser = argparse.ArgumentParser(
description="Run ROCgdb test suite with different compilers.",
formatter_class=lambda prog: argparse.RawDescriptionHelpFormatter(
prog, width=100
),
epilog="""Examples:
python %(prog)s --testsuite-dir /path/to/testsuite --rocgdb-bin /path/to/rocgdb
python %(prog)s --rocgdb-bin /path/to/rocgdb
python %(prog)s --tests gdb.base/break.exp gdb.base/call-ar-st.exp
python %(prog)s --gpu-tests
python %(prog)s --cpu-tests
python %(prog)s --parallel
python %(prog)s --group-results
python %(prog)s --default-timeout 300
python %(prog)s --retry-timeout 600
python %(prog)s --max-failed-retries 2
python %(prog)s --optimization="-O0"
python %(prog)s --runtestflags="--target_board=hip -debug"
python %(prog)s --no-xfail
python %(prog)s --quiet
python %(prog)s --skip-failed-test-log
python %(prog)s --output-ignore-list-file custom_ignore_list.json
python %(prog)s --one-by-one --tests gdb.rocm/foo.exp
""",
)
parser.add_argument(
"--skip-failed-test-log",
action="store_false",
dest="dump_failed_test_log",
help="Skip dumping gdb.log to the console for failed tests. Default is to dump the log.",
)
parser.add_argument(
"--group-results",
action="store_true",
help="Group test results in summary output. Default is off.",
)
parser.add_argument(
"--max-failed-retries",
type=_non_negative_int,
default=3,
help="Maximum number of times to retry failed tests. Default is 3.",
)
parser.add_argument(
"--no-xfail",
action="store_true",
help="Do not use ignore lists for failed tests. Default is to use the ignore lists.",
)
parser.add_argument(
"--retry-ignored-tests",
action="store_true",
help="Retry tests that are on the ignore list when they fail. "
"Default is off: ignored tests that fail on the first run are not retried.",
)
parser.add_argument(
"--optimization",
type=str,
default="",
help="Optimization level to pass to compiler (e.g., -O0, -Os, -Og).",
)
parser.add_argument(
"--parallel", action="store_true", help="Run tests in parallel. Default is off."
)
parser.add_argument(
"-j",
"--jobs",
type=_positive_nonzero_int,
default=None,
metavar="N",
help="Number of parallel jobs for make (e.g., -j4). Only valid with --parallel. "
"If --parallel is used without -j, defaults to os.cpu_count().",
)
parser.add_argument(
"--quiet",
action="store_true",
help="Do not output configure/testsuite commands. Default is off.",
)
parser.add_argument(
"--rocgdb-bin",
type=Path,
help="Path to the ROCgdb executable. Validated for existence when "
"supplied. Default: <rocm_dir>/bin/rocgdb, where rocm_dir is "
"auto-discovered (see --try-rocm-path).",
)
parser.add_argument(
"--runtestflags",
type=str,
default="",
help="Additional flags for RUNTESTFLAGS (e.g., '--target_board=hip -debug').",
)
# Create mutually exclusive group for test selection
test_group = parser.add_mutually_exclusive_group()
test_group.add_argument(
"--tests",
nargs="+",
default=None,
help="List of tests to run (individual .exp files or directories). "
"For directories, the /*.exp suffix is automatically added. "
"Mutually exclusive with --gpu-tests and --cpu-tests. "
"Default (if no test option specified) is gdb.rocm/*.exp and gdb.dwarf2/*.exp",
)
test_group.add_argument(
"--gpu-tests",
action="store_true",
help="Run GPU-specific tests (gdb.rocm directory only). "
"Mutually exclusive with --tests and --cpu-tests.",
)
test_group.add_argument(
"--cpu-tests",
action="store_true",
help="Run all CPU tests (all testsuite directories except gdb.rocm). "
"Automatically discovers all gdb.* directories with .exp files. "
"Mutually exclusive with --tests and --gpu-tests.",
)
parser.add_argument(
"--testsuite-dir",
type=Path,
help="Path to the GDB testsuite directory. Validated for existence "
"when supplied. Default: <rocm_dir>/tests/rocgdb/gdb/testsuite, "
"where rocm_dir is auto-discovered (see --try-rocm-path).",
)
parser.add_argument(
"--default-timeout",
type=_timeout_value,
default=None,
help="Default timeout in seconds for all test runs from the start. "
"If not specified, no timeout is set initially.",
)
parser.add_argument(
"--retry-timeout",
type=_timeout_value,
default=100,
help="Timeout in seconds for timeout-based retry runs only. "
"Only applies when retrying tests that timed out. Default is 100.",
)
parser.add_argument(
"--try-rocm-path",
action="store_true",
help="Try to use ROCM_PATH environment variable to determine the ROCm tree root. "
"If ROCM_PATH is set and valid, it takes precedence over OUTPUT_ARTIFACTS_DIR and script location detection. "
"If ROCM_PATH is not set, falls back to OUTPUT_ARTIFACTS_DIR or script location detection. "
"If ROCM_PATH is invalid, issues an error.",
)
parser.add_argument(
"--check-type",
type=str,
choices=["check", "check-read1", "check-readmore"],
default="check",
help="Which GDB testsuite target to invoke. 'check' is the normal run. "
"'check-read1' runs under an LD_PRELOAD shim that forces read(2) to "
"return 1 byte at a time (stress-tests GDB's incremental I/O parsing). "
"'check-readmore' forces large reads (the opposite extreme). These "
"are upstream GDB testsuite modes and can surface latent testcase "
"regex bugs that 'check' alone misses. Default: 'check'.",
)
parser.add_argument(
"--ignore-list-file",
type=Path,
required=False,
metavar="PATH",
help="Path to JSON file containing test ignore lists. "
"If not specified, uses rocgdb_ignore_list.json in the same directory as this script. "
"This option requires a value.",
)
parser.add_argument(
"--output-ignore-list-file",
type=Path,
required=False,
metavar="PATH",
help="Path to output JSON file for generating a new ignore list from test failures. "
"When specified, the testsuite runs normally and all failures are written to this file "
"at the end of the run. Common failures go into 'Generic' category, compiler-specific "
"failures go into compiler-specific categories (e.g., 'GCC', 'LLVM'). "
"This option requires a value.",
)
parser.add_argument(
"--one-by-one",
action="store_true",
help="Run each test in its own `make check` invocation and save its gdb.log "
"under --one-by-one-log-dir. Each invocation is subject to a wall-clock limit; "
"see --one-by-one-test-timeout. Mutually exclusive with --parallel.",
)
parser.add_argument(
"--one-by-one-log-dir",
type=Path,
default=None,
metavar="PATH",
help="Root directory for per-test gdb.log captures in --one-by-one mode. "
"Default: <testsuite_dir>/one_by_one_logs.",
)
parser.add_argument(
"--one-by-one-test-timeout",
type=_positive_nonzero_int,
default=None,
metavar="SECS",
help="Wall-clock timeout (seconds) per individual `make check` invocation "
"in --one-by-one mode. Defaults to 3600.",
)
args = parser.parse_args()
# Validate that -j/--jobs is only used with --parallel.
if args.jobs is not None and not args.parallel:
_log_error_and_exit(
"-j/--jobs can only be specified when --parallel is provided."
)
# Validate --one-by-one constraints.
if args.one_by_one and args.parallel:
_log_error_and_exit("--one-by-one cannot be combined with --parallel.")
if (
args.one_by_one_log_dir is not None or args.one_by_one_test_timeout is not None
) and not args.one_by_one:
_log_error_and_exit(
"--one-by-one-log-dir / --one-by-one-test-timeout require --one-by-one."
)
# Validate paths independently if provided. Either flag may be supplied
# alone; whichever is omitted is auto-discovered in _resolve_rocm_paths.
if args.testsuite_dir is not None:
args.testsuite_dir = validate_path(args.testsuite_dir, is_dir=True)
if args.rocgdb_bin is not None:
args.rocgdb_bin = validate_path(args.rocgdb_bin, is_file=True)
# Adjust retry-timeout if default-timeout is larger.
if args.default_timeout is not None and args.default_timeout > args.retry_timeout:
logger.warning(
f"--retry-timeout is being overridden from {args.retry_timeout}s to "
f"{args.default_timeout}s because --default-timeout is larger."
)
args.retry_timeout = args.default_timeout
return args
class TestResults:
"""Class to store and manage test results across multiple compiler runs."""
def __init__(self) -> None:
"""Initialize test results storage with empty data structures."""
# Flag to control whether to group results in output.
self.group_results: bool = False
# Our main data structure for storing results. The mapping goes like this:
#
# - 1 compiler label maps to N labels (PASS, FAIL etc).
# - 1 label maps to N test files (gdb.rocm/simple.exp for example).
# - 1 test file maps to N test descriptions (the complete test
# line output by dejagnu).
self.test_data = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
# Track currently failed tests by compiler_label.
self.failed_tests: Dict[str, Set[str]] = defaultdict(set)
# Track flaky tests (failed initially but passed on retry) by compiler_label.
self.flaky_tests: Dict[str, Set[str]] = defaultdict(set)
# Track harness-level errors (ERROR lines with no preceding
# "Running ....exp") per compiler_label. Replaced on each
# update_results call so it reflects the most recent run's state.
self.harness_errors: Dict[str, List[str]] = defaultdict(list)
def cleanup_old_entries(self, compiler_label: str, tests: List[str]) -> None:
"""
Remove stale test results before retry runs to prevent duplicate entries.
Args:
compiler_label: Compiler identifier (e.g., "GCC", "LLVM").
tests: List of test file names to remove.
Returns:
None
"""
# Case: first run for this compiler — nothing to clean. Use
# `in` rather than indexing to avoid the defaultdict factory
# materializing a phantom empty entry for the compiler.
if compiler_label not in self.test_data:
return
logger.info(f"Retry run. Cleaning dictionary entries for {' '.join(tests)}.")
# Clear the failed tests set for this compiler label.
self.failed_tests[compiler_label].clear()
# Clear tests entries from the main test results database for this
# compiler label.
for test in tests:
for category in TEST_CATEGORIES:
category_results = self.test_data[compiler_label].get(category)
if category_results and test in category_results:
del category_results[test]
def update_results(
self, compiler_label: str, tests: List[str], results_file: Path
) -> None:
"""
Parse and update test results from a DejaGnu results file.
A single pass over the file extracts both result lines (PASS/FAIL/...)
and ERROR lines, attributing each ERROR to the most recently seen
`Running ....exp` line. ERRORs without a preceding Running line are
recorded as harness-level errors.
Args:
compiler_label: Compiler name (e.g., "GCC", "LLVM").
tests: List of test file names to clear before updating.
results_file: Path to results file with lines like "STATUS: test_description".
Returns:
None
"""
self.cleanup_old_entries(compiler_label, tests)
new_errors: Dict[str, List[str]] = defaultdict(list)
orphan_errors: List[str] = []
current_test_file: Optional[str] = None
for line in _read_file_lines(results_file, "during results update"):
if not line:
continue
running_match = RUNNING_EXP_RE.search(line)
if running_match:
segments = os.path.normpath(running_match.group(1)).split(os.path.sep)
current_test_file = "/".join(segments[-2:])
if line.startswith("ERROR:"):
msg = line[len("ERROR:") :].strip()
if current_test_file is None:
orphan_errors.append(msg)
else:
new_errors[current_test_file].append(f"{current_test_file}: {msg}")
continue
match = RESULT_LINE_RE.match(line)
if match:
status, test_name = match.groups()
test_file = _extract_test_file_from_name(test_name)
is_timeout = TIMEOUT_RE.search(test_name)
# Add the entry to our test results database.
self.test_data[compiler_label][status][test_file].append(test_name)
# For timeouts, we add a duplicate since we want to
# explicitly list tests that ran into timeouts.
if is_timeout:
self.test_data[compiler_label]["TIMEOUT"][test_file].append(
test_name
)
# Track the current list of failed tests. Timeouts on
# statuses other than FAIL/UNRESOLVED (e.g. a KFAIL that
# happened to time out) are harmless and intentionally
# excluded.
if status in ["FAIL", "UNRESOLVED"]:
self.failed_tests[compiler_label].add(test_file)
# Merge ERROR entries into the existing dict so that ERRORs for tests
# not being rerun are preserved (cleanup_old_entries has already
# cleared the slots for the tests in the current run).
for test_file, messages in new_errors.items():
self.test_data[compiler_label]["ERROR"][test_file] = messages
self.failed_tests[compiler_label].add(test_file)
# Replace harness errors with this run's orphans.
self.harness_errors[compiler_label] = orphan_errors
def get_failed_tests(self, compiler_label: str) -> List[str]:
"""
Get currently failed tests for a compiler.
Args:
compiler_label: Compiler identifier.
Returns:
List of failed test file paths.
"""
# Use .get to avoid materializing a phantom empty entry for
# compilers that ran without any failures.
return list(self.failed_tests.get(compiler_label, set()))
def mark_flaky_tests(self, compiler_label: str, test_files: Set[str]) -> None:
"""
Mark tests as flaky (failed initially but passed on retry).
Args:
compiler_label: Compiler identifier (e.g., "GCC", "LLVM").
test_files: Set of test file paths that were flaky.
Returns:
None
"""
if not test_files:
return
# Add to flaky tests set.
self.flaky_tests[compiler_label].update(test_files)
# Add to test_data under FLAKY category with marker.
for test_file in test_files:
flaky_description = f"{test_file}: initially failed, passed on retry"
self.test_data[compiler_label]["FLAKY"][test_file].append(flaky_description)
logger.info(
f"{STATUS_FLAKY} {len(test_files)} test(s) became stable after retry for {compiler_label}"
)
def print_all_summaries(self) -> None:
"""
Print test summaries for all compilers.
Returns:
None
"""
if not self.test_data:
logger.info(f"\n{STATUS_FAIL} No test results to display.")
return
print_section("COMBINED GDB TESTSUITE ANALYSIS")
for compiler_label in sorted(self.test_data.keys()):
self._print_summary(compiler_label, self.test_data[compiler_label])
self.print_comparison()
def _print_summary(
self, compiler_label: str, details: Dict[str, Dict[str, List[str]]]
) -> None:
"""
Print test summary for a single compiler.
Args:
compiler_label: Compiler identifier.
details: Mapping from status category to test files and descriptions.
Returns:
None
"""
print_section(f"{compiler_label}", border_char="-", inline=True)
for cat, prefix_indicator in CATEGORY_DISPLAY.items():
count = sum(len(tests) for tests in details.get(cat, {}).values())
if count > 0:
header = f" {prefix_indicator} {cat}: {count}"
logger.info(header)
# Print details for non-PASS tests.
if count > 0 and cat != "PASS":
if self.group_results:
self._print_grouped_tests(details[cat])
else:
for test_file in details[cat]:
for test_name in details[cat][test_file]:
logger.info(f" {test_name}")
harness_errors = self.harness_errors.get(compiler_label, [])
if harness_errors:
logger.info(f" {STATUS_FAIL} HARNESS ERRORS: {len(harness_errors)}")
for msg in harness_errors:
logger.info(f" {msg}")
def _print_grouped_tests(self, test_list: Dict[str, List[str]]) -> None:
"""
Print tests grouped by directory, file, and description.
Args:
test_list: Mapping of test file paths to descriptions.
Returns:
None
"""
grouped = defaultdict(lambda: defaultdict(list))
for file, descriptions in test_list.items():
test_dir, test_file = os.path.split(file)
prefix_len = len(file) + 2
for test_description in descriptions:
grouped[test_dir][test_file].append(test_description[prefix_len:])
for directory in sorted(grouped.keys()):
logger.info(f" * {directory}")
for filename in sorted(grouped[directory].keys()):
descriptions = grouped[directory][filename]
if len(descriptions) == 1 and descriptions[0]:
logger.info(f" * {filename}: {descriptions[0]}")
elif len(descriptions) == 1:
logger.info(f" * {filename}")
else:
logger.info(f" * {filename}")
for desc in descriptions:
if desc:
logger.info(f" * {desc}")
def print_comparison(self) -> None:
"""
Print failing tests exclusive to each compiler.
Returns:
None
"""
if len(self.failed_tests) <= 1:
return
print_section("EXCLUSIVE FAILING TESTS COMPARISON")
for compiler_label in sorted(self.failed_tests.keys()):
other_failed = set.union(
*(
self.failed_tests[other]
for other in self.failed_tests
if other != compiler_label
)
)
exclusive_tests = self.failed_tests[compiler_label] - other_failed
print_section(f"{compiler_label}", border_char="-", inline=True)
if exclusive_tests:
for test in sorted(exclusive_tests):
logger.info(f" {STATUS_FAIL} {test}")
else:
logger.info(" No exclusive failing tests.")
def check_all_pass_or_xfailed(
self, xfailed_tests: Dict[str, List[str]]
) -> Tuple[bool, Dict[str, Dict]]:
"""
Check if all tests passed or are in the expected failure list.
Args:
xfailed_tests: Compiler labels mapped to expected failing test paths.
Must include "Generic" key for cross-compiler failures.
Returns:
Tuple of (overall_pass, details) where:
- overall_pass: True if no unexpected failures.
- details: Per-compiler dict with passed status, total_failed count,
unexpected_failures, unused_xfails, expected_generic,
expected_compiler_specific lists, and flaky_tests count.
"""
overall_pass = True
details = {}
generic_xfailed = set(xfailed_tests.get("Generic", []))
# Include all compilers that ran tests, so all-passing compilers also
# show up in the final status report.
all_compilers = set(self.test_data.keys())
for compiler_label in all_compilers:
failed_test_files = set(self.failed_tests.get(compiler_label, set()))
compiler_xfailed = set(xfailed_tests.get(compiler_label, []))
all_xfailed = generic_xfailed | compiler_xfailed
unexpected_failures = failed_test_files - all_xfailed
unused_xfails = all_xfailed - failed_test_files
expected_generic = failed_test_files & generic_xfailed
expected_compiler_specific = failed_test_files & compiler_xfailed
harness_errors = self.harness_errors.get(compiler_label, [])
has_harness_errors = bool(harness_errors)
compiler_pass = len(unexpected_failures) == 0 and not has_harness_errors
overall_pass = overall_pass and compiler_pass
flaky_count = len(self.flaky_tests.get(compiler_label, set()))
details[compiler_label] = {
"passed": compiler_pass,
"total_failed": len(failed_test_files),
"unexpected_failures": sorted(unexpected_failures),
"unused_xfails": sorted(unused_xfails),
"expected_generic": sorted(expected_generic),
"expected_compiler_specific": sorted(expected_compiler_specific),
"flaky_tests": sorted(self.flaky_tests.get(compiler_label, set())),
"flaky_count": flaky_count,
"harness_errors": list(harness_errors),
}
return overall_pass, details
def generate_ignore_list(self) -> Dict[str, List[str]]:
"""
Generate ignore list from current test failures.
Analyzes failures across all compilers and categorizes them as:
- Generic: Tests that failed for all compilers
- Compiler-specific: Tests that failed only for specific compilers
Returns:
Dictionary with keys "Generic" and compiler labels (e.g., "GCC", "LLVM"),
each mapping to a sorted list of failed test file paths.
"""
# Collect all failed test files per compiler that ran tests.
all_compiler_failures = {}
for compiler_label in sorted(self.test_data.keys()):
failed_test_files = set(self.failed_tests.get(compiler_label, set()))
all_compiler_failures[compiler_label] = failed_test_files
# Determine which failures are common to all compilers.
if len(all_compiler_failures) > 1:
generic_failures = set.intersection(*all_compiler_failures.values())
else:
# 0 compilers (no runs) or 1 compiler (all failures are compiler-specific).
generic_failures = set()
# Build the ignore list structure.
ignore_list = {"Generic": sorted(generic_failures)}
# Add compiler-specific failures.
for compiler_label in sorted(all_compiler_failures.keys()):
compiler_specific = all_compiler_failures[compiler_label] - generic_failures
ignore_list[compiler_label] = sorted(compiler_specific)
return ignore_list
def print_final_status(
self, xfailed_tests: Dict[str, List[str]], no_xfail: bool
) -> bool:
"""
Print final PASS/FAIL status for all compilers.
Args:
xfailed_tests: Compiler labels mapped to expected failures.
no_xfail: If True, ignore the expected failure lists.
Returns:
True if all tests passed or are expected failures, False otherwise.
"""
overall_pass, details = self.check_all_pass_or_xfailed(
{} if no_xfail else xfailed_tests
)
print_section("FINAL TEST STATUS")
for compiler_label in sorted(details.keys()):
detail = details[compiler_label]
status_symbol = STATUS_PASS if detail["passed"] else STATUS_FAIL
status_text = "PASS" if detail["passed"] else "FAIL"
logger.info(f"{status_symbol} {compiler_label}: {status_text}")
if detail["total_failed"]:
logger.info(
f"{STATUS_FAIL} Total Failed Tests: {detail['total_failed']}"
)
if detail["flaky_count"] > 0:
logger.info(
f"{STATUS_FLAKY} Flaky Tests (passed after retry) ({detail['flaky_count']}):"
)
for test in detail["flaky_tests"]:
logger.info(f" {test}")
if detail["expected_generic"]:
logger.info(
f"{STATUS_WARN} Ignored Failures (Generic) ({len(detail['expected_generic'])}):"
)
for test in detail["expected_generic"]:
logger.info(f" {test}")
if detail["expected_compiler_specific"]:
logger.info(
f"{STATUS_WARN} Ignored Failures ({compiler_label}) ({len(detail['expected_compiler_specific'])}):"
)
for test in detail["expected_compiler_specific"]:
logger.info(f" {test}")
if detail["unexpected_failures"]:
logger.info(
f"{STATUS_FAIL} Unexpected Failures ({len(detail['unexpected_failures'])}):"
)
for test in detail["unexpected_failures"]:
logger.info(f" {test}")
if detail["harness_errors"]:
logger.info(
f"{STATUS_FAIL} Harness Errors ({len(detail['harness_errors'])}):"
)
for msg in detail["harness_errors"]:
logger.info(f" {msg}")
if detail["unused_xfails"]:
logger.info(
f"{STATUS_WARN} Unused Ignored Failures ({len(detail['unused_xfails'])}):"
)
for test in detail["unused_xfails"]:
logger.info(f" {test}")
logger.info("")
overall_status = "PASS" if overall_pass else "FAIL"
overall_symbol = STATUS_PASS if overall_pass else STATUS_FAIL
print_section(f"{overall_symbol} OVERALL STATUS: {overall_status}")
logger.info("")
return overall_pass
def print_section(
title: str,
border_char: str = "=",
width: int = 80,
center: bool = True,
inline: bool = False,
color: Optional[str] = None,
) -> None:
"""
Print a formatted section header to console.
Args:
title: Text to display in the header.
border_char: Character for border line (default "=").
width: Total header width (default 80).
center: Center title in multi-line mode (default True).
inline: Single-line format if True, multi-line if False (default False).
color: ANSI color code (e.g., "\\033[92m" for green).
Returns:
None
"""
reset = "\033[0m"
apply_color = (
(lambda text: f"{color}{text}{reset}") if color else (lambda text: text)
)
# Always add a newline to the beginning of the section.
logger.info("")
if inline:
# Prepare inline title.
title_str = f" {title} "
remaining = width - len(title_str)
if remaining < 0:
remaining = 0
left = border_char * (remaining // 2)
right = border_char * (remaining - len(left))
logger.info(apply_color(f"{left}{title_str}{right}"))
else:
# Multi-line section style.
border = border_char * width
title_line = f"{title:^{width}}" if center else title
logger.info(apply_color(border))
logger.info(apply_color(title_line))
logger.info(apply_color(border))
def _log_aligned_fields(
fields: List[Union[str, Tuple[str, str]]],
indent: str = " ",
) -> None:
"""
Log (label, value) pairs with colons aligned to the longest label with an associated value.
Plain string entries are logged as-is (pass-through lines), skipping the
colon and padding — useful for pass-through lines mixed in with key/value rows.
Args:
fields: List of (label, value) tuples or plain strings for pass-through lines.
indent: Leading whitespace prefix for every row.
Returns:
None
"""
if not fields:
return
labelled = [entry[0] for entry in fields if isinstance(entry, tuple)]
width = max(len(label) for label in labelled) if labelled else 0
for entry in fields:
if isinstance(entry, str):
logger.info(f"{indent}{entry}")
else:
label, value = entry
logger.info(f"{indent}{label:<{width}} : {value}")
def validate_required_files(required_files: Dict[str, Path]) -> None:
"""
Validate presence of required files and print status.
Args:
required_files: File descriptions mapped to filesystem paths.
Returns:
None
Exits:
Code 1 if any required file is missing.
"""
print_section("Required files")
all_valid = True
fields: List[Tuple[str, str]] = []
for name, path in required_files.items():
present = path.is_file()
status = STATUS_PASS if present else STATUS_FAIL
fields.append((f"{status} {name}", str(path)))
if not present:
all_valid = False
_log_aligned_fields(fields)
if not all_valid:
_log_error_and_exit("One or more required files are missing.")
def _prepend_to_path_var(env_vars: Dict[str, str], name: str, parts: List[str]) -> None:
"""
Prepend `parts` to a path-style env var in `env_vars`.
The caller is responsible for logging the change (so multiple env-var
updates can be printed as a single aligned block).
Args:
env_vars: Environment variable dictionary to mutate.
name: Name of the path-style variable (e.g., "PATH", "LD_LIBRARY_PATH").
parts: New entries to prepend, in order.
Returns:
None
"""
existing = env_vars.get(name, "")
env_vars[name] = os.pathsep.join(parts + ([existing] if existing else []))
def setup_sanitizer_environment(env_vars: Dict[str, str], testsuite_dir: Path) -> None:
"""
Pre-configure *SAN_OPTIONS for every sanitizer we know about.
This runs unconditionally. When rocgdb is not sanitizer-instrumented,
the matching env vars are ignored by the (absent) sanitizer runtime,
so the only side effect of a normal run is the creation of an empty
sanitizer log directory.
For each sanitizer in SANITIZERS we set:
halt_on_error=0 - sanitizer findings do not abort the run
detect_leaks=0 - asan only. ASan ships LSan integrated;
silence leak reports by default. Override
with ASAN_OPTIONS=detect_leaks=1 for a
dedicated leak run.
log_path=<dir>/log - reports go to <dir>/log.<pid>; the
sanitizer runtime appends .<pid> per
process, so one log file is produced
per process. All sanitizers share a
single directory because log_path lives
in the sanitizer common flags - in a
combined build (e.g. ASan + UBSan) the
last-initialized runtime would overwrite
any per-sanitizer log_path. Reports
self-identify in their content.
suppressions=<file> - only when the .supp template exists;
empty suppression files are still
attached intentionally so users can
populate the templates without code
changes. suppressions is per-runtime
and does not collide.
If the shared log directory cannot be created (e.g. read-only
testsuite mount), the log_path option is omitted and the sanitizer
falls back to stderr - still non-fatal.
"""
print_section("Sanitizer environment")
suppressions_dir = Path(__file__).resolve().parent / "sanitizers"
log_dir = testsuite_dir / "sanitizers_output"
log_dir_ready = True
try:
log_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
log_dir_ready = False
logger.warning(
f"{STATUS_WARN} Could not create sanitizer log dir "
f"{log_dir}: {exc}. Sanitizer reports will go to stderr."
)
fields: List[Tuple[str, str]] = []
for sanitizer in SANITIZERS:
options = ["halt_on_error=0"]
if sanitizer == "asan":
options.append("detect_leaks=0")
if log_dir_ready:
options.append(f"log_path={log_dir}/log")
suppression_file = suppressions_dir / f"{sanitizer}.supp"
if suppression_file.is_file():
options.append(f"suppressions={suppression_file}")
env_var_name = f"{sanitizer.upper()}_OPTIONS"
# Preserve any pre-existing options from the caller's environment.
# Sanitizer option parsing is last-wins, so appending the existing
# value after our defaults lets the user override them.
existing = env_vars.get(env_var_name, "")
if existing:
options.append(existing)
env_vars[env_var_name] = ":".join(options)
fields.append((env_var_name, env_vars[env_var_name]))
_log_aligned_fields(fields)
def setup_environment(artifacts_dir: Path) -> Dict[str, str]:
"""
Configure environment variables for test suite execution.
Args:
artifacts_dir: Path to artifacts directory with binaries and libraries.
Returns:
Updated environment variables dictionary.
"""
print_section("Setting up environment variables")
# Copy current environment.
env_vars = os.environ.copy()
# Add ROCgdb and LLVM binaries to PATH and libraries to LD_LIBRARY_PATH.
#
# Please note LLVM has switched location for its executables/libraries.
# It used to be llvm/bin (and llvm/lib) but now is lib/llvm/bin (and
# lib/llvm/lib). A llvm -> lib/llvm symlink is kept for backwards
# compatibility, but the new path should be used moving forward.
path_parts = [
f"{artifacts_dir}/bin",
f"{artifacts_dir}/llvm/bin",
f"{artifacts_dir}/lib/llvm/bin",
]
ld_library_parts = [
f"{artifacts_dir}/lib",
f"{artifacts_dir}/llvm/lib",
f"{artifacts_dir}/lib/llvm/lib",
]
# Determine target triple
target_triple = None
amdclang_path = artifacts_dir / "lib" / "llvm" / "bin" / "amdclang++"
if amdclang_path.exists():
try:
target_triple = subprocess.run(
[str(amdclang_path), "--print-target-triple"],
capture_output=True,
text=True,
timeout=10,
check=True,
).stdout.strip()
except (subprocess.SubprocessError, OSError) as exc:
raise RuntimeError(
f"'{amdclang_path} --print-target-triple' failed; "
"this suggests a broken toolchain."
) from exc
if target_triple:
ld_library_parts += [f"{artifacts_dir}/lib/llvm/lib/{target_triple}"]
_prepend_to_path_var(env_vars, "PATH", path_parts)
_prepend_to_path_var(env_vars, "LD_LIBRARY_PATH", ld_library_parts)
# Check if we are running within a github actions context, where a
# non-system version of Python is being used. If so, we have
# pythonLocation set to the base location of the Python interpreter.
if "pythonLocation" in env_vars:
# Set PYTHONHOME so rocgdb can initialize Python properly.
env_vars["PYTHONHOME"] = env_vars["pythonLocation"]
pythonhome_value = env_vars["PYTHONHOME"]
else:
pythonhome_value = "not set (no pythonLocation env var)"
_log_aligned_fields(
[
("PATH (prepended)", os.pathsep.join(path_parts)),
("LD_LIBRARY_PATH (prepended)", os.pathsep.join(ld_library_parts)),
("PYTHONHOME", pythonhome_value),
]
)
return env_vars
def check_executables(executables: List[str], env_vars: Dict[str, str]) -> None:
"""
Verify required executables are in the prepared PATH.
Args:
executables: List of executable names to check.
env_vars: Prepared environment supplying the PATH used for the lookup.
Returns:
None
Exits:
Code 1 if any executable is missing.
"""
print_section("Required executables")
missing = []
search_path = env_vars.get("PATH")
for exe in executables:
path = shutil.which(exe, path=search_path)
if path:
logger.info(f"{STATUS_PASS} {exe:15} found at: {path}")
else:
missing.append(exe)
logger.info(f"{STATUS_FAIL} {exe:15} NOT found on PATH")
if missing:
_log_error_and_exit(f"Missing {len(missing)} executables required for testing.")
def setup_core_file_info() -> None:
"""
Set system core file size limit to unlimited and display core file pattern.
The outcome is communicated to the user via logger.info / logger.warning
so the result is visible in the run banner.
"""
print_section("Core file information")
# Display current core file pattern.
core_pattern_file = Path("/proc/sys/kernel/core_pattern")
try:
if core_pattern_file.exists():
core_pattern = core_pattern_file.read_text().strip()
logger.info(f"System core file pattern: {core_pattern}")
else:
logger.info("System core file pattern: N/A (file not found)")
except OSError as e:
logger.warning(f"System core file pattern: N/A (unable to read: {e})")
try:
resource.setrlimit(
resource.RLIMIT_CORE, (resource.RLIM_INFINITY, resource.RLIM_INFINITY)
)
soft, hard = resource.getrlimit(resource.RLIMIT_CORE)
if soft == resource.RLIM_INFINITY:
logger.info(f"{STATUS_PASS} Core file size limit set to unlimited")
else:
logger.warning(f"Core file size limit is {soft}, not unlimited")
logger.warning("Core file tests may not execute properly")
except (OSError, ValueError) as e:
logger.error(f"{STATUS_FAIL} Unable to set core file size limit: {e}")
logger.warning("Core file tests will not be executed")
def cleanup_test_suite(
test_suite_dir: Path, env_vars: Dict[str, str], quiet: bool = False
) -> None:
"""
Clean build artifacts and recreate site.exp configuration.
Args:
test_suite_dir: Path to test suite directory.
env_vars: Environment variables for cleanup commands.
quiet: Suppress command output if True (default False).
Returns:
None
Exits:
Code 1 if cleanup fails.
"""
logger.info("Cleaning test suite directory...")
cmd = ["make", "clean"]
logger.info(f"Executing cleanup: {shlex.join(cmd)}")
_run_command(
cmd,
cwd=test_suite_dir,
env=env_vars,
capture_output=quiet,
error_msg="Failed to clean test directory",
)
logger.info("Removing old site.exp and site.bak...")
for filename in ["site.exp", "site.bak"]:
(test_suite_dir / filename).unlink(missing_ok=True)
logger.info("Creating site.exp...")
cmd = ["make", "site.exp"]
logger.info(f"Executing: {shlex.join(cmd)}")
_run_command(
cmd,
cwd=test_suite_dir,
env=env_vars,
capture_output=quiet,
error_msg="Failed to create site.exp",
)
def configure_test_suite(
test_suite_dir: Path, env_vars: Dict[str, str], quiet: bool = False
) -> None:
"""
Run test suite configuration script.
Args:
test_suite_dir: Path to test suite directory with configure script.
env_vars: Environment variables for configuration.
quiet: Suppress command output if True (default False).
Returns:
None
Exits:
Code 1 if configuration fails.
"""
configure_script = test_suite_dir / "configure"
cmd = ["sh", str(configure_script)]
logger.info(f"Executing: {shlex.join(cmd)}")
_run_command(
cmd,
cwd=test_suite_dir,
env=env_vars,
capture_output=quiet,
error_msg="Test suite configuration failed",
)
cleanup_test_suite(test_suite_dir, env_vars, quiet)
def set_test_timeout(test_suite_dir: Path, timeout_value: int) -> None:
"""
Set gdb_test_timeout in site.exp configuration file.
Args:
test_suite_dir: Path to test suite directory with site.exp file.
timeout_value: Timeout in seconds for test runs.
Returns:
None
Exits:
Code 1 if site.exp is missing or cannot be written.
"""
site_exp_file = test_suite_dir / "site.exp"
if not site_exp_file.is_file():
_log_error_and_exit(f"site.exp not found at {site_exp_file}")
try:
with open(site_exp_file, "a", encoding="utf-8") as f:
f.write(f"\nset gdb_test_timeout {timeout_value}\n")
logger.info(
f"{STATUS_PASS} Successfully set gdb_test_timeout to {timeout_value} in {site_exp_file}"
)
except OSError as e:
_log_error_and_exit(f"Failed to write timeout to {site_exp_file}: {e}")
def discover_test_directories(
testsuite_dir: Path, exclude: Optional[List[str]] = None
) -> List[str]:
"""
Discover all test directories in the testsuite.
Args:
testsuite_dir: Path to testsuite directory.
exclude: List of directory names to exclude (default None).
Returns:
Sorted list of test directory names (e.g., ["gdb.base", "gdb.threads"]).
"""
if exclude is None:
exclude = []
test_dirs = []
for item in testsuite_dir.iterdir():
if item.is_dir() and item.name.startswith("gdb.") and item.name not in exclude:
if any(item.glob("*.exp")):
test_dirs.append(item.name)
return sorted(test_dirs)
def expand_test_paths(test_list: List[str], testsuite_dir: Path) -> List[str]:
"""
Expand test paths to a list of .exp files, validating existence.
Args:
test_list: Test paths (individual .exp files or directories).
testsuite_dir: Base directory to resolve relative paths.
Returns:
List of .exp test file paths.
Exits:
Code 1 if any test file is missing.
"""
expanded_tests: List[str] = []
for test_path in test_list:
if not test_path.endswith(".exp"):
pattern = f"{test_path}/*.exp"
matches = sorted(glob.glob(pattern, root_dir=str(testsuite_dir)))
if matches:
expanded_tests.extend(matches)
logger.info(
f"Expanded directory '{test_path}' to {len(matches)} test files"
)
else:
logger.warning(f"No test files found matching pattern: {pattern}")
else:
expanded_tests.append(test_path)
logger.info(f"Added test file: {test_path}")
# Drop duplicates while preserving first-occurrence order so that
# overlapping --tests arguments do not silently run the same test twice.
deduped_tests = list(dict.fromkeys(expanded_tests))
if len(deduped_tests) != len(expanded_tests):
dropped = len(expanded_tests) - len(deduped_tests)
logger.warning(
f"Dropped {dropped} duplicate test path(s) from the expanded test list"
)
expanded_tests = deduped_tests
# Verify that all expanded files exist.
for file_path in expanded_tests:
abs_path = testsuite_dir / file_path
if not abs_path.is_file():
_log_error_and_exit(f"Missing or invalid test file: {file_path}")
return expanded_tests
def print_env_variables() -> None:
"""
Print all environment variables for debugging.
Returns:
None
"""
print_section("Environment Variables")
_log_aligned_fields([(key, value) for key, value in os.environ.items()])
def validate_path(path: Path, is_dir: bool = False, is_file: bool = False) -> Path:
"""
Validate path exists and matches expected type.
Args:
path: Path to validate.
is_dir: Require directory if True (default False).
is_file: Require file if True (default False).
Returns:
The resolved (absolute, symlink-followed) path.
Exits:
Code 1 if path doesn't exist or is wrong type.
"""
resolved_path = path.resolve()
if is_dir and not resolved_path.is_dir():
_log_error_and_exit(f"Directory does not exist: {resolved_path}")
if is_file and not resolved_path.is_file():
_log_error_and_exit(f"File does not exist: {resolved_path}")
return resolved_path
def validate_rocgdb(rocgdb_bin: Path, env_vars: Dict[str, str]) -> None:
"""
Validate ROCgdb executable can run successfully.
Args:
rocgdb_bin: Path to ROCgdb executable.
env_vars: Prepared environment (PATH/LD_LIBRARY_PATH/sanitizer options)
to use for the validation subprocesses.
Returns:
None
Exits:
Code 1 if ROCgdb fails to execute or its python validation fails.
"""
print_section("ROCgdb launcher data")
try:
# First invoke the rocgdb launcher in debug mode.
print_section("ROCgdb launcher start", border_char="-", inline=True)
env = env_vars.copy()
env["ROCGDB_WRAPPER_DEBUG"] = "1"
result = _run_command(
[str(rocgdb_bin), "--version"], env=env, capture_output=True
)
for line in result.stderr.splitlines():
logger.info(line)
logger.info("ROCgdb launcher ran successfully.")
# Now validate that we can launch rocgdb at all.
print_section("ROCgdb executable start", border_char="-", inline=True)
result = _run_command(
[str(rocgdb_bin), "--version"], env=env_vars, capture_output=True
)
for line in result.stdout.splitlines():
logger.info(line)
logger.info("ROCgdb executable ran successfully.")
except subprocess.CalledProcessError as e:
_log_error_and_exit(f"ROCgdb did not run successfully: {e.stderr}")
# Validate internal Python environment.
print_section("ROCgdb internal python data", border_char="-", inline=True)
py_cmd = (
"import sys, os, sysconfig, gdb; "
"v = sys.version_info; "
"version = f'{v.major}.{v.minor}.{v.micro}'; "
"supports = bool(sysconfig.get_config_var('Py_ENABLE_SHARED')); "
"lib_dir = sysconfig.get_config_var('LIBDIR'); "
"ld_lib = sysconfig.get_config_var('LDLIBRARY'); "
"lib_path = os.path.join(lib_dir, ld_lib) if (supports and lib_dir and ld_lib) else 'N/A'; "
"print(f'Executable: {sys.executable}'); "
"print(f'Version: {version}'); "
"print(f'Supports libpython: {supports}'); "
"print(f'libpython Path: {lib_path}'); "
'print(f\'libpython Version: {sysconfig.get_config_var("VERSION") or "N/A"}\'); '
"print(f'GDB python modules path: {gdb.PYTHONDIR}'); "
)
result = _run_command(
[str(rocgdb_bin), "-batch", "-ex", f"python {py_cmd}"],
env=env_vars,
capture_output=True,
check=False,
)
if result.returncode == 0:
# Parse "label: value" pairs printed by the embedded script and
# re-emit them with colons aligned, matching the surrounding
# banner blocks. Lines without a colon are passed through verbatim.
parsed: List[Union[str, Tuple[str, str]]] = []
for line in result.stdout.splitlines():
label, sep, value = line.partition(":")
if sep:
parsed.append((label.strip(), value.strip()))
else:
parsed.append(line)
_log_aligned_fields(parsed)
logger.info("ROCgdb internal python validation successful.")
elif "Python scripting is not supported in this copy of GDB" in result.stderr:
logger.warning(
"Python scripting is not supported in this copy of GDB. Testing will proceed without Python support."
)
else:
_log_error_and_exit(f"ROCgdb python validation failed: {result.stderr}")
def run_tests(
test_suite_dir: Path,
rocgdb_bin: Path,
env_vars: Dict[str, str],
tests: List[str],
cc: str,
cxx: str,
fc: str,
compiler_label: str,
test_results: "TestResults",
args: argparse.Namespace,
xfailed_tests: Optional[Dict[str, List[str]]] = None,
) -> None:
"""
Run ROCgdb test suite with retry logic for failed tests.
Args:
test_suite_dir: Path to test suite directory.
rocgdb_bin: Path to ROCgdb binary.
env_vars: Environment variables for test execution.
tests: Test file names to run.
cc: C compiler command.
cxx: C++ compiler command.
fc: Fortran compiler command.
compiler_label: Compiler identifier (e.g., "GCC").
test_results: TestResults object for storing results.
args: Parsed command-line arguments.
xfailed_tests: Compiler labels mapped to expected failing test paths.
Used to skip retrying ignored tests when --retry-ignored-tests is off.
Returns:
None
"""
max_iterations = 1 + args.max_failed_retries
current_tests = tests
for iteration in range(1, max_iterations + 1):
print_section(f"ROCgdb tests - {compiler_label} - Iteration {iteration}")
logger.info(f"Number of tests to run: {len(current_tests)}")
# Track which tests we're running in this iteration (for flaky detection).
tests_in_iteration = set(current_tests)
configure_test_suite(test_suite_dir, env_vars, args.quiet)
# default_timeout applies on every iteration. On retry iterations
# following a TIMEOUT, use retry_timeout instead (parse_arguments
# guarantees it is >= default_timeout).
timeout_to_set = args.default_timeout
if iteration > 1 and test_results.test_data.get(compiler_label, {}).get(
"TIMEOUT"
):
timeout_to_set = args.retry_timeout
if timeout_to_set is not None:
set_test_timeout(test_suite_dir, timeout_to_set)
start_time = time.perf_counter()
runtestflags_str = _build_runtestflags(
rocgdb_bin, cc, cxx, fc, args.optimization, args.runtestflags
)
if args.one_by_one:
if args.one_by_one_test_timeout is not None:
wall_clock = args.one_by_one_test_timeout
else:
# gdb_test_timeout is per-testcase, not per-.exp. Default
# generously and let the user override via --one-by-one-test-timeout.
wall_clock = ONE_BY_ONE_DEFAULT_WALL_CLOCK_TIMEOUT
logger.info(
f"Using default one-by-one wall-clock timeout: {wall_clock}s "
f"(override with --one-by-one-test-timeout)"
)
log_dir = args.one_by_one_log_dir or (test_suite_dir / "one_by_one_logs")
_execute_one_by_one(
test_suite_dir,
current_tests,
runtestflags_str,
args.check_type,
args.quiet,
env_vars,
log_dir,
compiler_label,
iteration,
wall_clock,
)
else:
cmd = [
"make",
args.check_type,
f"RUNTESTFLAGS={runtestflags_str}",
f"TESTS={' '.join(current_tests)}",
]
if args.parallel:
jobs = args.jobs or os.cpu_count()
cmd += ["FORCE_PARALLEL=1", f"-j{jobs}"]
logger.info(
f"Executing tests with {compiler_label} - Iteration {iteration}: {shlex.join(cmd)}"
)
_run_command(
cmd,
cwd=test_suite_dir,
env=env_vars,
capture_output=args.quiet,
check=False,
kill_process_group=True,
)
duration = time.perf_counter() - start_time
print_section(
f"Tests with {compiler_label} - Iteration {iteration} completed in {duration:.4f} seconds."
)
# Parse test results from gdb.sum.
results_file = test_suite_dir / "gdb.sum"
test_results.update_results(compiler_label, current_tests, results_file)
failed_tests = test_results.get_failed_tests(compiler_label)
# Detect flaky tests: tests we ran this iteration that now have PASS
# entries (and are no longer in failed_tests). Restrict to tests we
# actually ran so tests missing from gdb.sum aren't misreported as flaky.
if iteration > 1:
passed_this_iteration = {
test_file
for test_file in test_results.test_data.get(compiler_label, {}).get(
"PASS", {}
)
if test_file in tests_in_iteration
}
failed_test_files = set(failed_tests)
newly_passed = passed_this_iteration - failed_test_files
if newly_passed:
test_results.mark_flaky_tests(compiler_label, newly_passed)
if not failed_tests:
logger.info(
f"{STATUS_PASS} No failing tests for {compiler_label}. Stopping iterations."
)
break
elif iteration < max_iterations:
# Collect the failed tests for the next iteration. By default,
# tests on the ignore list are not retried: they are known failures
# and re-running them (potentially many times, for two compilers)
# adds significant wall-clock time for no diagnostic value.
# --retry-ignored-tests or --no-xfail restore the old behaviour.
retry_candidates = set(failed_tests)
if not args.retry_ignored_tests and not args.no_xfail and xfailed_tests:
all_xfailed = set(xfailed_tests.get("Generic", [])) | set(
xfailed_tests.get(compiler_label, [])
)
skipped = retry_candidates & all_xfailed
retry_candidates -= all_xfailed
if skipped:
logger.info(
f"{STATUS_WARN} Skipping retry of {len(skipped)} ignored test(s) "
f"for {compiler_label} (use --retry-ignored-tests to retry them):"
)
for t in sorted(skipped):
logger.info(f" {t}")
if not retry_candidates:
logger.info(
f"{STATUS_PASS} All remaining failures for {compiler_label} are on the "
f"ignore list. Stopping iterations."
)
break
current_tests = sorted(retry_candidates)
logger.info(
f"{STATUS_FAIL} {len(current_tests)} failing test(s) found for {compiler_label}. "
f"Proceeding to iteration {iteration + 1} with only failed tests."
)
else:
logger.info(
f"{STATUS_FAIL} {len(failed_tests)} failing test(s) remain for {compiler_label} "
f"after {max_iterations} iterations."
)
# Print the contents of gdb.log in a visually uncluttered way.
# Skipped in --one-by-one mode since per-test logs are already on disk.
if args.dump_failed_test_log and not args.one_by_one:
gdb_log_file = test_suite_dir / "gdb.log"
if gdb_log_file.is_file():
print_section("Contents of gdb.log")
# Read directly rather than via _read_file_lines: gdb.log
# uses leading whitespace for nesting, which .strip() would
# flatten and make the failure dump much harder to read.
try:
contents = gdb_log_file.read_text(
encoding="utf-8", errors="replace"
)
except OSError as e:
logger.warning(f"Failed to read {gdb_log_file}: {e}")
else:
for line in contents.splitlines():
logger.info(line)
else:
logger.warning(
f"gdb.log not found at {gdb_log_file}; skipping dump."
)
def _resolve_rocm_paths(args: argparse.Namespace) -> Tuple[Path, Path, Path]:
"""
Resolve the ROCm tree root, ROCgdb binary, and testsuite directory.
The ROCm tree root is always auto-discovered from:
ROCM_PATH (if --try-rocm-path) > OUTPUT_ARTIFACTS_DIR > script location.
The binary and testsuite paths are then derived from that root unless
the user overrode either with --rocgdb-bin / --testsuite-dir, in which
case the supplied path is honored verbatim. The two override flags are
independent — supplying one does not require the other.
Args:
args: Parsed command-line arguments.
Returns:
Tuple of (rocm_dir, rocgdb_bin, rocgdb_testsuite_dir).
Exits:
Code 1 if a configured environment-variable path does not exist.
"""
rocm_dir = None
if args.try_rocm_path:
rocm_path_env = os.getenv("ROCM_PATH")
if rocm_path_env is not None:
tmp_dir = Path(rocm_path_env).resolve()
if not tmp_dir.is_dir():
_log_error_and_exit(
f"ROCM_PATH is set to '{tmp_dir}' but the path does not exist"
)
rocm_dir = tmp_dir
logger.info(f"Using ROCM_PATH: {rocm_dir}")
else:
logger.info(
"--try-rocm-path flag set but ROCM_PATH environment variable is not set. "
"Falling back to OUTPUT_ARTIFACTS_DIR or script location."
)
if rocm_dir is None:
output_artifacts_dir = os.getenv("OUTPUT_ARTIFACTS_DIR")
if output_artifacts_dir is not None:
# OUTPUT_ARTIFACTS_DIR is set, verify it exists.
rocm_dir = Path(output_artifacts_dir).resolve()
if not rocm_dir.is_dir():
_log_error_and_exit(
f"OUTPUT_ARTIFACTS_DIR is set to '{rocm_dir}' but the path does not exist"
)
logger.info(f"Using OUTPUT_ARTIFACTS_DIR: {rocm_dir}")
else:
# OUTPUT_ARTIFACTS_DIR is not set. Fall back to the script's own
# location: when installed, this file lives at
# <rocm_tree>/tests/rocgdb/test_rocgdb.py, so three .parent hops
# land on <rocm_tree>. This branch only produces a usable path
# when run from the installed location, not from the source repo.
script_path = Path(__file__).resolve()
rocm_dir = script_path.parent.parent.parent
logger.info(f"Using script-based path: {rocm_dir}")
rocgdb_bin = (
args.rocgdb_bin if args.rocgdb_bin is not None else rocm_dir / "bin" / "rocgdb"
)
rocgdb_testsuite_dir = (
args.testsuite_dir
if args.testsuite_dir is not None
else rocm_dir / "tests" / "rocgdb" / "gdb" / "testsuite"
)
return rocm_dir, rocgdb_bin, rocgdb_testsuite_dir
def main() -> None:
"""
Main entry point for ROCgdb test suite runner.
Orchestrates argument parsing, environment setup, test execution,
and result reporting for GCC and LLVM compilers.
Returns:
None
Exits:
Code 0 if all tests pass or are expected failures, Code 1 otherwise.
"""
args = parse_arguments()
# Determine ignore list file path.
if args.ignore_list_file is None:
# Default to rocgdb_ignore_list.json in the same directory as this script.
script_dir = Path(__file__).parent
args.ignore_list_file = script_dir / "rocgdb_ignore_list.json"
else:
# User explicitly supplied a path — it must exist.
args.ignore_list_file = validate_path(args.ignore_list_file, is_file=True)
# Determine ignore list file status for configuration display.
if args.ignore_list_file.exists():
ignore_list_file_status = f"Reading from {args.ignore_list_file}"
else:
ignore_list_file_status = "disabled"
# Determine output ignore list file status for configuration display.
if args.output_ignore_list_file is not None:
output_ignore_list_file_status = f"Outputting to {args.output_ignore_list_file}"
else:
output_ignore_list_file_status = "disabled"
start_time = time.perf_counter()
rocm_dir, rocgdb_bin, rocgdb_testsuite_dir = _resolve_rocm_paths(args)
rocgdb_configure_script = rocgdb_testsuite_dir / "configure"
# Handle test selection based on command-line options.
# This must happen before print_configuration() since it displays args.tests.
if args.gpu_tests:
args.tests = ["gdb.rocm"]
logger.info("Using --gpu-tests: running gdb.rocm tests only")
elif args.cpu_tests:
args.tests = discover_test_directories(
rocgdb_testsuite_dir, exclude=["gdb.rocm"]
)
if not args.tests:
_log_error_and_exit("No CPU test directories found in testsuite")
logger.info(
f"Using --cpu-tests: discovered {len(args.tests)} CPU test directories"
)
elif args.tests is None:
# Default case: no test option was specified.
args.tests = ["gdb.rocm", "gdb.dwarf2"]
logger.info("Using default tests: gdb.rocm and gdb.dwarf2")
# Print env variables.
print_env_variables()
# Print Python information.
print_python_info()
# Show configuration summary.
print_configuration(
rocgdb_bin,
rocgdb_testsuite_dir,
rocgdb_configure_script,
rocm_dir,
args,
ignore_list_file_status,
output_ignore_list_file_status,
)
# Load ignore list from JSON file.
xfailed_tests = load_ignore_list_from_json(args.ignore_list_file)
# Validate critical files exist.
validate_required_files(
{
"ROCgdb launcher": rocgdb_bin,
"ROCgdb configure script": rocgdb_configure_script,
}
)
if platform.system() == "Linux":
# Set core dump file limit and display info.
setup_core_file_info()
# Prepare environment for tests.
env_vars = setup_environment(rocm_dir)
setup_sanitizer_environment(env_vars, rocgdb_testsuite_dir)
# Validate that we can run rocgdb.
validate_rocgdb(rocgdb_bin, env_vars)
# Verify executables presence.
check_executables(
[
"make",
"amdclang++",
"gcc",
"g++",
"gfortran",
"clang",
"clang++",
"flang",
"runtest",
],
env_vars,
)
print_section("Expanding test paths")
tests = expand_test_paths(args.tests, rocgdb_testsuite_dir)
if not tests:
_log_error_and_exit("No test files found")
# Compiler configurations.
compilers = [
("gcc", "g++", "gfortran", "GCC"),
("clang", "clang++", "flang", "LLVM"),
]
# Initialize test result tracking.
test_results = TestResults()
test_results.group_results = args.group_results
# Run tests for all configured compilers.
for cc, cxx, fc, compiler_label in compilers:
run_tests(
rocgdb_testsuite_dir,
rocgdb_bin,
env_vars,
tests,
cc,
cxx,
fc,
compiler_label,
test_results,
args,
xfailed_tests,
)
# Final summaries.
test_results.print_all_summaries()
overall_pass = test_results.print_final_status(xfailed_tests, args.no_xfail)
# Generate and write output ignore list if requested.
if args.output_ignore_list_file is not None:
ignore_list = test_results.generate_ignore_list()
try:
with open(args.output_ignore_list_file, "w", encoding="utf-8") as f:
json.dump(ignore_list, f, indent=2, ensure_ascii=False)
f.write("\n") # Add trailing newline.
logger.info(
f"{STATUS_PASS} Generated ignore list written to {args.output_ignore_list_file}"
)
except OSError as e:
print_section("Generated ignore list (write failed)")
for line in json.dumps(
ignore_list, indent=2, ensure_ascii=False
).splitlines():
logger.info(line)
_log_error_and_exit(
f"Failed to write ignore list to {args.output_ignore_list_file}: {e}"
)
logger.info("")
duration = time.perf_counter() - start_time
logger.info(f"Total test run duration: {duration:.4f} seconds.")
sys.exit(0 if overall_pass else 1)
def _build_runtestflags(
rocgdb_bin: Path,
cc: str,
cxx: str,
fc: str,
optimization: str,
runtestflags: str,
) -> str:
"""
Build RUNTESTFLAGS string for test execution.
Args:
rocgdb_bin: Path to ROCgdb binary.
cc: C compiler command.
cxx: C++ compiler command.
fc: Fortran compiler command.
optimization: Optimization flags.
runtestflags: Additional flags.
Returns:
Complete RUNTESTFLAGS string.
"""
parts = [
f"GDB={shlex.quote(str(rocgdb_bin))}",
f"CC_FOR_TARGET={shlex.quote(cc)}",
f"CXX_FOR_TARGET={shlex.quote(cxx)}",
f"F77_FOR_TARGET={shlex.quote(fc)}",
f"F90_FOR_TARGET={shlex.quote(fc)}",
"HIP_COMPILER_FOR_TARGET=amdclang++",
]
if optimization:
parts.append(f"CFLAGS_FOR_TARGET={shlex.quote(optimization)}")
if runtestflags:
parts.append(runtestflags)
return " ".join(parts)
def _execute_one_by_one(
test_suite_dir: Path,
tests: List[str],
runtestflags_str: str,
check_type: str,
quiet: bool,
env_vars: Dict[str, str],
log_dir: Path,
compiler_label: str,
iteration: int,
wall_clock_timeout: int,
) -> None:
"""
Run each test in its own `make check` invocation, capturing per-test
gdb.log under <log_dir>/<compiler>/{pass,fail}/<group>/ and writing a
sibling .status file with timing and iteration metadata. Per-test gdb.sum
content is aggregated into the canonical gdb.sum so the existing
result-parsing path consumes it unchanged.
A test is bucketed into "fail/" if its per-test gdb.sum contains any line
starting with FAIL:, UNRESOLVED:, or ERROR:, or if the wall-clock timeout
fires; otherwise "pass/".
Args:
test_suite_dir: Path to test suite directory.
tests: Test files to run individually (e.g. ["gdb.rocm/foo.exp"]).
runtestflags_str: RUNTESTFLAGS string built by _build_runtestflags.
check_type: GDB testsuite target to invoke (check / check-read1 / check-readmore).
quiet: Capture subprocess output if True.
env_vars: Environment variables for test execution.
log_dir: Root directory for per-test gdb.log captures.
compiler_label: Compiler identifier (e.g., "GCC").
iteration: Current retry iteration (1-based) — controls log filename suffix.
wall_clock_timeout: Wall-clock timeout (seconds) for each `make check`.
Returns:
None
Exits:
Code 1 if the aggregated gdb.sum cannot be written.
"""
aggregated_lines: List[str] = []
retry_number = iteration - 1
base_suffix = "" if retry_number == 0 else f".retry-{retry_number}"
for test in tests:
cmd = [
"make",
check_type,
f"RUNTESTFLAGS={runtestflags_str}",
f"TESTS={test}",
]
logger.info(f"[one-by-one] {compiler_label} - {test}: {shlex.join(cmd)}")
# Purge stale gdb.sum / gdb.log so a killed or failing `make` cannot
# cause this iteration to read the previous test's leftovers.
for stale in (test_suite_dir / "gdb.sum", test_suite_dir / "gdb.log"):
try:
stale.unlink()
except FileNotFoundError:
pass
except OSError as e:
logger.warning(
f"{STATUS_WARN} {test}: failed to clear stale {stale.name}: {e}"
)
wall_clock_timed_out = False
start = time.perf_counter()
try:
_run_command(
cmd,
cwd=test_suite_dir,
env=env_vars,
capture_output=quiet,
check=False,
timeout=wall_clock_timeout,
kill_process_group=True,
)
except subprocess.TimeoutExpired:
wall_clock_timed_out = True
logger.warning(
f"{STATUS_WARN} {test}: wall-clock timeout after {wall_clock_timeout}s"
)
duration = time.perf_counter() - start
# Read per-test gdb.sum for both bucketing and aggregation.
gdb_sum = test_suite_dir / "gdb.sum"
per_test_lines: List[str] = []
if gdb_sum.is_file():
per_test_lines = _read_file_lines(
gdb_sum, f"during one-by-one aggregation of {test}"
)
elif not wall_clock_timed_out:
# `make` either exited before runtest could produce gdb.sum or
# runtest itself failed without writing one. Without this
# synthesized line the test would contribute nothing to the
# aggregated gdb.sum and silently count as a pass.
logger.warning(
f"{STATUS_WARN} {test}: gdb.sum not produced; "
f"recording as UNRESOLVED."
)
per_test_lines.append(
f"UNRESOLVED: {test}: one-by-one make produced no gdb.sum"
)
# On wall-clock timeout, synthesize a FAIL line so update_results
# tracks the timeout. The "(timeout)" suffix triggers the existing
# TIMEOUT classification via TIMEOUT_RE.
if wall_clock_timed_out:
per_test_lines.append(
f"FAIL: {test}: one-by-one wall-clock timeout (timeout)"
)
aggregated_lines.extend(per_test_lines)
# Determine pass/fail using the same definition as TestResults, and
# pick the most informative status label by scanning for the highest
# severity prefix present (TIMEOUT > ERROR > UNRESOLVED > FAIL > PASS).
has_error = has_unresolved = has_fail = False
for line in per_test_lines:
if line.startswith("ERROR:"):
has_error = True
elif line.startswith("UNRESOLVED:"):
has_unresolved = True
elif line.startswith("FAIL:"):
has_fail = True
test_failed = wall_clock_timed_out or has_fail or has_unresolved or has_error
status_dir = "fail" if test_failed else "pass"
if wall_clock_timed_out:
status_text = "TIMEOUT"
elif has_error:
status_text = "ERROR"
elif has_fail:
status_text = "FAIL"
elif has_unresolved:
status_text = "UNRESOLVED"
else:
status_text = "PASS"
test_path = Path(test)
target_dir = log_dir / compiler_label / status_dir / test_path.parent
target_dir.mkdir(parents=True, exist_ok=True)
log_target = target_dir / f"{test_path.stem}{base_suffix}.log"
status_target = target_dir / f"{test_path.stem}{base_suffix}.status"
gdb_log = test_suite_dir / "gdb.log"
if gdb_log.is_file():
try:
shutil.copy2(gdb_log, log_target)
except OSError as e:
logger.warning(
f"{STATUS_WARN} {test}: failed to copy gdb.log to {log_target}: {e}"
)
else:
logger.warning(
f"{STATUS_WARN} {test}: gdb.log not produced; skipping log capture."
)
status_content = (
f"test: {test}\n"
f"compiler: {compiler_label}\n"
f"iteration: {iteration}\n"
f"retry_number: {retry_number}\n"
f"status: {status_text}\n"
f"duration_seconds: {duration:.3f}\n"
f"wall_clock_timeout: {bool(wall_clock_timed_out)}\n"
)
try:
status_target.write_text(status_content, encoding="utf-8")
except OSError as e:
logger.warning(
f"{STATUS_WARN} {test}: failed to write status file {status_target}: {e}"
)
aggregated_sum = test_suite_dir / "gdb.sum"
try:
aggregated_sum.write_text("\n".join(aggregated_lines) + "\n", encoding="utf-8")
except OSError as e:
_log_error_and_exit(f"Failed to write aggregated gdb.sum: {e}")
def print_python_info() -> None:
"""
Print Python interpreter information including version and libpython details.
Returns:
None
"""
print_section("Python information")
version = (
f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
)
supports_libpython = bool(sysconfig.get_config_var("Py_ENABLE_SHARED"))
lib_path = "N/A"
lib_version = "N/A"
if supports_libpython:
lib_dir = sysconfig.get_config_var("LIBDIR")
ld_library = sysconfig.get_config_var("LDLIBRARY")
if lib_dir and ld_library:
lib_path = os.path.join(lib_dir, ld_library)
lib_version = str(sysconfig.get_config_var("VERSION") or "N/A")
lib_path_exists = lib_path != "N/A" and os.path.exists(lib_path)
if not supports_libpython:
supports_value = "No"
elif lib_path_exists:
supports_value = "Yes"
else:
supports_value = "Supported but libpython is missing"
lib_path_value = (
f"{lib_path} (missing)"
if lib_path != "N/A" and not lib_path_exists
else lib_path
)
_log_aligned_fields(
[
("Executable", sys.executable),
("Version", version),
("Supports libpython", supports_value),
("libpython Path", lib_path_value),
("libpython Version", lib_version),
]
)
def print_configuration(
rocgdb_bin: Path,
testsuite_dir: Path,
configure_script: Path,
rocm_tree_root: Path,
args: argparse.Namespace,
ignore_list_file_status: str,
output_ignore_list_file_status: str,
) -> None:
"""
Display the ROCgdb test configuration in a formatted table.
Args:
rocgdb_bin: Path to the ROCgdb binary.
testsuite_dir: Path to the test suite directory.
configure_script: Path to the configure script for the test suite.
rocm_tree_root: Path to the root of the ROCm tree.
args: Parsed command-line arguments containing additional configuration values.
ignore_list_file_status: Status message for ignore list file.
output_ignore_list_file_status: Status message for output ignore list file.
Returns:
None
Notes:
- Prints an ASCII table summarizing binary location, directories, test selection,
and execution settings.
- Fields come directly from parsed CLI arguments.
"""
if not args.parallel:
parallel_info = "Disabled"
elif args.jobs is None:
parallel_info = f"Enabled ({os.cpu_count()} jobs, from cpu_count)"
else:
parallel_info = f"Enabled ({args.jobs} jobs)"
default_timeout_display = (
f"{args.default_timeout} seconds"
if args.default_timeout is not None
else "Dejagnu's default"
)
one_by_one_timeout_display = (
f"{args.one_by_one_test_timeout} seconds"
if args.one_by_one_test_timeout is not None
else f"{ONE_BY_ONE_DEFAULT_WALL_CLOCK_TIMEOUT} seconds (auto)"
)
fields = [
("OS", platform.system()),
("ROCm Directory", rocm_tree_root),
("ROCgdb Binary", rocgdb_bin),
("Testsuite Directory", testsuite_dir),
("Configure Script", configure_script),
("Tests", " ".join(args.tests)),
("Check Type", args.check_type),
("Parallel Execution", parallel_info),
("One-by-one Mode", "Enabled" if args.one_by_one else "Disabled"),
]
if args.one_by_one:
fields.append(
(
"One-by-one Log Dir",
args.one_by_one_log_dir or (testsuite_dir / "one_by_one_logs"),
)
)
fields.append(("One-by-one Test Timeout", one_by_one_timeout_display))
fields.extend(
[
("Use FAIL ignore list", "Not using" if args.no_xfail else "Using"),
(
"Retry ignored tests",
"Enabled" if args.retry_ignored_tests else "Disabled",
),
("Group Results", "Enabled" if args.group_results else "Disabled"),
("Default Timeout", default_timeout_display),
("Retry Timeout", f"{args.retry_timeout} seconds"),
("Max Failed Retries", args.max_failed_retries),
("Optimization", args.optimization or "None"),
("Additional Runtest Flags", args.runtestflags or "None"),
("Ignore List", ignore_list_file_status),
("Output Ignore List", output_ignore_list_file_status),
]
)
print_section("ROCgdb Test Suite Configuration")
_log_aligned_fields([(label, str(value)) for label, value in fields])
if __name__ == "__main__":
main()