import argparse
import os
import re
import subprocess
import sys
import time
from typing import List, Optional
DEFAULT_BATCH_M = 10
DEFAULT_BATCH_N = 100
ALL_OSES = ['openeuler', 'opensuse', 'fedora', 'debian', 'ubuntu', 'archlinux', 'alpine', 'conda']
def script_name() -> str:
"""Get the script name for display in usage and debug output."""
return sys.argv[0]
def debug_solve(os_name: str, pkg_to_install: str, grep_pkg: str = None):
"""Run debug solve with RUST_LOG=debug and output to /tmp/dd.
Usage: depends.py --debug --os=<OS> <pkg_to_install> [--grep=<PKG>]
Example:
depends.py --debug --os=fedora vtk
depends.py --debug --os=fedora vtk --grep=hdf5
"""
debug_log = '/tmp/dd'
cmd = ['epkg', '--assume-no', '-e', os_name, 'install', '--no-install-essentials', pkg_to_install]
import os as os_module
env = os_module.environ.copy()
env['RUST_LOG'] = 'debug'
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
debug_content = re.sub(r'^.*DEBUG ', '', result.stderr, flags=re.MULTILINE)
with open(debug_log, 'w') as f:
f.write(debug_content)
f.write(f"\n\nreproduce command:\n")
f.write(f" RUST_LOG=debug epkg --assume-no -e {os_name} install --no-install-essentials {pkg_to_install}\n")
f.write(f"\npackage metadata query command:\n")
f.write(f" epkg -e {os_name} info {pkg_to_install}\n")
if grep_pkg:
f.write(f"\n epkg -e {os_name} info {grep_pkg}\n")
f.write(f"\ndebug log grep command:\n")
f.write(f" grep -F -C3 '{grep_pkg}' {debug_log}\n")
print(f"Debug log written to {debug_log}")
if grep_pkg:
grep_result = subprocess.run(
['grep', '-F', '-C3', grep_pkg, debug_log],
capture_output=True, text=True
)
if grep_result.stdout:
print(f"\nGrep output for '{grep_pkg}':")
print(grep_result.stdout)
subprocess.run(['less', debug_log])
def load_error_whitelist() -> List[str]:
"""Load error whitelist patterns from a text file."""
script_dir = os.path.dirname(os.path.abspath(__file__))
whitelist_file = os.path.join(script_dir, 'whitelist.txt')
whitelist = []
try:
with open(whitelist_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
whitelist.append(line)
except FileNotFoundError:
pass
except Exception as e:
print(f"Warning: Failed to load error whitelist from {whitelist_file}: {e}", file=sys.stderr)
return whitelist
ERROR_WHITELIST = load_error_whitelist()
def usage():
"""Print usage information."""
script_name = sys.argv[0]
print(f"""Usage: {script_name} [OPTIONS] [PACKAGES...]
Test package dependencies across different OS environments.
Options:
--debug Debug mode: run RUST_LOG=debug for single package
--grep=<PKG> In debug mode, grep this package in debug log
--solver=<SOLVER> Dependency solver to use: 'constraints' or 'simple'
--os=<OS_LIST> Limit testing to specified OSes (comma-separated, e.g., debian,ubuntu)
--batch=MxN Customize batch size: M packages per batch, N batches (default: {DEFAULT_BATCH_M}x{DEFAULT_BATCH_N})
--seed=<SEED> Random seed for package selection (default: current time)
Arguments:
PACKAGES... Space-separated list of packages to test
If not specified, randomly selects packages from available list per OS
Examples:
{script_name} # Test random packages (10x100 batches) on all OSes
{script_name} --os=debian,ubuntu # Test random packages on debian and ubuntu only
{script_name} --batch=5x20 --os=fedora # Test 5x20 random packages on fedora only
{script_name} --seed=12345 # Test with specific random seed
{script_name} bash curl # Test bash and curl on all OSes
{script_name} --solver=constraints bash curl # Test with constraints solver
{script_name} --os=debian --solver=simple wine # Test wine on debian with simple solver
{script_name} --debug --os=fedora vtk # Debug mode: run with RUST_LOG=debug
{script_name} --debug --os=fedora vtk --grep=hdf5 # Debug + grep for hdf5
Typical Stress Test + Debug Workflow:
Run these in parallel in separate terminals:
{script_name} --os=debian
{script_name} --os=ubuntu
{script_name} --os=openeuler
{script_name} --os=opensuse
{script_name} --os=fedora
{script_name} --os=archlinux
{script_name} --os=alpine
{script_name} --os=conda
On test error:
1. If it's a real package missing/conflicting issue:
vim tests/fuzz/whitelist.txt
# Add the broken package pattern to ignore
2. If it's a solver bug:
# Run the debug_solve() function shown in the error output
# Or manually run with RUST_LOG=debug and grep for pkg_no_candidate
3. In rare cases, you may need to grep and check repodata:
grep <pkg_no_candidate> ~/.cache/epkg/channels/<os>-*/*/*/provide2pkgnames.yaml
grep <pkg_no_candidate> ~/.cache/epkg/channels/<os>-*/*/*/packages.txt
""")
def parse_args(args: List[str]) -> argparse.Namespace:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description='Test package dependencies across different OS environments.',
add_help=False
)
parser.add_argument('-h', '--help', action='store_true', help='Show help message')
parser.add_argument('--debug', action='store_true', help='Debug mode: run RUST_LOG=debug for single package')
parser.add_argument('--grep', type=str, help='In debug mode, grep this package in debug log')
parser.add_argument('--solver', type=str, choices=['constraints', 'simple'],
help="Dependency solver to use: 'constraints' or 'simple'")
parser.add_argument('--os', type=str,
help='Limit testing to specified OSes (comma-separated, e.g., debian,ubuntu)')
parser.add_argument('--batch', type=str, default=f'{DEFAULT_BATCH_M}x{DEFAULT_BATCH_N}',
help=f'Customize batch size: M packages per batch, N batches (default: {DEFAULT_BATCH_M}x{DEFAULT_BATCH_N})')
parser.add_argument('--seed', type=int, default=int(time.time()),
help='Random seed for package selection (default: current time)')
parser.add_argument('packages', nargs='*', help='Packages to test')
parsed = parser.parse_args(args)
if parsed.batch:
try:
m_str, n_str = parsed.batch.split('x')
parsed.batch_m = int(m_str)
parsed.batch_n = int(n_str)
except ValueError:
print(f"Error: Invalid batch format '{parsed.batch}'. Must be MxN (e.g., 10x100)", file=sys.stderr)
sys.exit(1)
else:
parsed.batch_m = DEFAULT_BATCH_M
parsed.batch_n = DEFAULT_BATCH_N
if parsed.os:
parsed.selected_oses = [os.strip() for os in parsed.os.split(',')]
else:
parsed.selected_oses = ALL_OSES
parsed.use_random_packages = len(parsed.packages) == 0
return parsed
def get_available_packages(os: str) -> List[str]:
"""Get available packages for an OS."""
try:
result = subprocess.run(
['epkg', '-e', os, 'list', '--available'],
capture_output=True,
text=True,
check=False
)
packages = []
for line in result.stdout.split('\n'):
if line.startswith('A'):
parts = line.split()
if len(parts) >= 8:
packages.append(parts[4])
return sorted(set(packages))
except Exception as e:
print(f"Warning: Failed to get available packages for {os}: {e}", file=sys.stderr)
return []
def filter_available_packages(os: str, packages: List[str]) -> List[str]:
"""Filter packages that are available in the OS."""
available_packages = set(get_available_packages(os))
return [pkg for pkg in packages if pkg in available_packages]
def select_random_packages(os: str, m: int, n: int, seed: int) -> List[str]:
"""Select random packages from available list using Python random."""
import random
total_needed = m * n
available_packages = get_available_packages(os)
if not available_packages:
print(f"Warning: No available packages found for {os}", file=sys.stderr)
return []
available_count = len(available_packages)
if available_count < total_needed:
print(f"Warning: Only {available_count} packages available for {os}, but {total_needed} needed", file=sys.stderr)
total_needed = available_count
random.seed(seed)
return random.sample(available_packages, total_needed)
def split_into_batches(m: int, packages: List[str]) -> List[List[str]]:
"""Split packages into batches."""
batches = []
for i in range(0, len(packages), m):
batches.append(packages[i:i + m])
return batches
def is_whitelisted_error(stdout: str, stderr: str) -> Optional[str]:
"""Check if error output matches any pattern in the whitelist.
Returns the matched pattern if found, None otherwise.
"""
combined_output = stdout + stderr
for pattern in ERROR_WHITELIST:
if pattern in combined_output:
return pattern
return None
def parse_dependency_error(stdout: str, stderr: str) -> Optional[tuple[str, str]]:
"""Parse dependency resolution error to extract package names.
Returns a tuple of (pkg_to_install, pkg_no_candidate) if found, None otherwise.
- pkg_to_install: Package from first error line (e.g., "qdldl-python")
- pkg_no_candidate: Package or capability from last error line (e.g., "pybind11-abi")
"""
combined_output = stdout + stderr
lines = combined_output.split('\n')
pkg_to_install = None
pkg_no_candidate = None
for i, line in enumerate(lines):
if "Dependency resolution failed for" in line:
for j in range(i + 1, min(i + 5, len(lines))):
next_line = lines[j]
match = re.search(r'\s+(\S+)\s+(\S+)\s+cannot', next_line)
if match:
pkg_to_install = match.group(1)
break
break
for line in reversed(lines):
if (
"for which no candidates were found" in line
or "which cannot be installed because there are no viable options" in line
or "which conflicts with any installable versions previously reported" in line
):
match = re.search(
r'\s+(\S+)\s+(\S+),\s+',
line,
)
if match:
pkg_no_candidate = match.group(1)
break
if pkg_to_install and pkg_no_candidate:
return (pkg_to_install, pkg_no_candidate)
return None
def run_install_test(os: str, batch_packages: List[str], solver_option: Optional[str]) -> bool:
"""Run install test for a batch."""
cmd = ['epkg', '--assume-no', '-e', os, 'install', '--no-install-essentials']
if solver_option:
cmd.append(solver_option)
cmd.extend(batch_packages)
cmdline = ' '.join(cmd)
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False
)
if result.returncode != 0:
matched_pattern = is_whitelisted_error(result.stdout, result.stderr)
if matched_pattern:
print(f" Failed command: {cmdline}")
print(f" (Error matches whitelist: '{matched_pattern}', continuing...)")
return True
print(f" Failed command:\n{cmdline}")
for line in result.stdout.split('\n'):
if line.strip():
print(f" {line}")
for line in result.stderr.split('\n'):
if line.strip():
print(f" {line}")
parsed_error = parse_dependency_error(result.stdout, result.stderr)
if parsed_error:
pkg_to_install, pkg_no_candidate = parsed_error
print(f"Debug command:")
print(f"epkg -e {os} info '{pkg_no_candidate}'")
print(f"{script_name()} --debug --os={os} '{pkg_to_install}' --grep='{pkg_no_candidate}'")
return False
else:
return True
except Exception as e:
print(f" Failed command: {cmdline}")
print(f" Exception: {e}")
return False
def test_os(os: str, args: argparse.Namespace) -> bool:
"""Test packages for a specific OS."""
print(f"Testing {os}")
try:
subprocess.run(
['epkg', 'env', 'create', os, '-c', os],
capture_output=True,
check=False
)
except Exception as e:
print(f" Warning: Failed to create environment: {e}", file=sys.stderr)
if args.use_random_packages:
packages_to_test = select_random_packages(os, args.batch_m, args.batch_n, args.seed)
if not packages_to_test:
print(f" Skipping {os}: no packages available")
return True
else:
packages_to_test = filter_available_packages(os, args.packages)
if not packages_to_test:
print(f" Skipping {os}: no specified packages available")
return True
batches = split_into_batches(args.batch_m, packages_to_test)
total_packages_tested = 0
for batch_num, batch_packages in enumerate(batches, 1):
solver_opt = f"--solver={args.solver}" if args.solver else None
if run_install_test(os, batch_packages, solver_opt):
total_packages_tested += len(batch_packages)
print(f"\r Batch {total_packages_tested} packages", end='', flush=True)
else:
print()
print(f" ERROR: Batch {batch_num} failed for {os}")
return False
print()
print(f" Completed {os}: {len(batches)} batches tested")
return True
def main():
"""Main function."""
args = parse_args(sys.argv[1:])
if args.help:
usage()
sys.exit(0)
if args.debug:
if not args.packages:
print("Error: --debug requires PACKAGE argument", file=sys.stderr)
sys.exit(1)
if not args.selected_oses or len(args.selected_oses) != 1:
print("Error: --debug requires single --os argument", file=sys.stderr)
sys.exit(1)
os_name = args.selected_oses[0]
pkg_to_install = args.packages[0]
pkg_no_candidate = args.grep
debug_solve(os_name, pkg_to_install, args.grep)
return
if args.help:
usage()
sys.exit(0)
print("Configuration:")
print(f" OSes: {', '.join(args.selected_oses)}")
print(f" Batch size: {args.batch_m}x{args.batch_n}")
print(f" Random seed: {args.seed}")
if args.use_random_packages:
print(" Mode: Random package selection")
else:
print(f" Mode: Specified packages: {' '.join(args.packages)}")
if args.solver:
print(f" Solver: --solver={args.solver}")
print()
for os in args.selected_oses:
if not test_os(os, args):
print(f"ERROR: Testing failed for {os}", file=sys.stderr)
sys.exit(1)
print()
print("All tests completed successfully")
if __name__ == '__main__':
main()