Files
wehub-resource-sync a0c8464e58
Build Package / build (ubuntu-latest) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Core Typecheck / core-typecheck (push) Failing after 1s
Linting / lint (push) Failing after 1s
llama-dev tests / test-llama-dev (push) Failing after 1s
Publish Sub-Package to PyPI if Needed / publish_subpackage_if_needed (push) Has been skipped
Sync Docs to Developer Hub / sync-docs (push) Failing after 0s
Build Package / build (windows-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:26:52 +08:00

560 lines
18 KiB
Python

import concurrent.futures
import os
import subprocess
import sys
import time
from enum import Enum, auto
from pathlib import Path
import click
from rich.live import Live
from rich.table import Table
from llama_dev.utils import (
find_all_packages,
get_changed_files,
get_changed_packages,
get_dep_names,
get_dependants_packages,
is_python_version_compatible,
load_pyproject,
package_has_tests,
)
class ResultStatus(Enum):
"""Represents the possible outcomes after shelling out pytest."""
INSTALL_FAILED = auto()
TESTS_FAILED = auto()
TESTS_PASSED = auto()
NO_TESTS = auto()
UNSUPPORTED_PYTHON_VERSION = auto()
COVERAGE_FAILED = auto()
NO_TESTS_INDICATOR = "no tests ran"
MAX_CONSOLE_PRINT_LINES = 50
def _generate_status_table(
total: int,
completed: int,
passed: int,
failed: int,
skipped: int,
running_packages: list[str],
) -> Table:
"""Generate a Rich table showing test progress."""
table = Table(title="Test Progress", show_header=True, header_style="bold cyan")
table.add_column("Status", style="dim", width=20)
table.add_column("Count", justify="right")
table.add_row("Total Packages", str(total))
table.add_row("Completed", f"{completed}/{total}")
table.add_row("✅ Passed", str(passed), style="green")
table.add_row("❌ Failed", str(failed), style="red")
table.add_row("⏭️ Skipped", str(skipped), style="yellow")
table.add_row("", "")
if running_packages:
table.add_row("Currently Running:", "", style="bold blue")
for pkg in running_packages[:10]: # Show max 10
table.add_row(" →", pkg, style="blue")
if len(running_packages) > 10:
table.add_row(
" →", f"... and {len(running_packages) - 10} more", style="dim blue"
)
return table
@click.command(short_help="Run tests across the monorepo")
@click.option(
"--fail-fast",
is_flag=True,
default=False,
help="Exit the command at the first failure",
)
@click.option(
"--cov",
is_flag=True,
default=False,
help="Compute test coverage",
)
@click.option(
"--cov-fail-under",
default=0,
help="Compute test coverage",
)
@click.option("--base-ref", required=False)
@click.option("--workers", default=8)
@click.argument("package_names", required=False, nargs=-1)
@click.pass_obj
def test(
obj: dict,
fail_fast: bool,
cov: bool,
cov_fail_under: int,
base_ref: str,
workers: int,
package_names: tuple,
):
# Fail on incompatible configurations
if cov_fail_under and not cov:
raise click.UsageError(
"You have to pass --cov in order to use --cov-fail-under"
)
if base_ref is not None and not base_ref:
raise click.UsageError("Option '--base-ref' cannot be empty.")
if not package_names and not base_ref:
raise click.UsageError(
"Either pass '--base-ref' or provide at least one package name."
)
console = obj["console"]
repo_root = obj["repo_root"]
debug: bool = obj["debug"]
packages_to_test: set[Path] = set()
all_packages = find_all_packages(repo_root)
if package_names:
changed_packages: set[Path] = {repo_root / Path(pn) for pn in package_names}
else:
# Get the files that changed from the base branch
changed_files = get_changed_files(repo_root, base_ref)
# Get the packages containing the changed files
changed_packages = get_changed_packages(changed_files, all_packages)
# Find the dependants of the changed packages
# Skip dependants if we're checking coverage
if cov:
dependants = set()
else:
dependants = get_dependants_packages(changed_packages, all_packages)
# Test the packages directly affected and their dependants
packages_to_test = changed_packages | dependants
# Test the packages using a process pool
results = []
passed_count = 0
failed_count = 0
skipped_count = 0
with concurrent.futures.ProcessPoolExecutor(max_workers=int(workers)) as executor:
future_to_package = {
executor.submit(
_run_tests,
package_path,
changed_packages,
base_ref,
cov,
cov_fail_under,
): package_path
for package_path in sorted(packages_to_test)
}
# Detect if we're in a CI environment (GitHub Actions, etc.)
is_ci = (
os.environ.get("CI") == "true" or os.environ.get("GITHUB_ACTIONS") == "true"
)
if is_ci:
# In CI: print periodic status updates instead of live display
console.print(
f"🚀 Starting tests for {len(packages_to_test)} packages with {workers} workers...\n"
)
last_update_time = time.time()
update_interval = 30 # Print status every 30 seconds
for future in concurrent.futures.as_completed(future_to_package):
result = future.result()
results.append(result)
# Update counts
if result["status"] == ResultStatus.TESTS_PASSED:
passed_count += 1
elif result["status"] in (
ResultStatus.TESTS_FAILED,
ResultStatus.COVERAGE_FAILED,
ResultStatus.INSTALL_FAILED,
):
failed_count += 1
else:
skipped_count += 1
# Print status update periodically
current_time = time.time()
if (
current_time - last_update_time >= update_interval
or len(results) % 10 == 0
or len(results) == len(packages_to_test)
):
running_packages = [
str(future_to_package[f].relative_to(repo_root))
for f in future_to_package
if not f.done()
]
console.print(
f"\n📊 Progress: {len(results)}/{len(packages_to_test)} | ✅ {passed_count} | ❌ {failed_count} | ⏭️ {skipped_count}"
)
if running_packages:
console.print(
f"🔄 Currently running ({len(running_packages)}):"
)
for pkg in running_packages[:15]: # Show up to 15 in CI
console.print(f" → {pkg}")
if len(running_packages) > 15:
console.print(
f" ... and {len(running_packages) - 15} more"
)
console.print()
last_update_time = current_time
else:
# Local: Use Rich Live display to show progress
with Live(
_generate_status_table(
len(packages_to_test),
0,
0,
0,
0,
[
str(p.relative_to(repo_root))
for p in sorted(packages_to_test)[: int(workers)]
],
),
console=console,
refresh_per_second=2,
) as live:
for future in concurrent.futures.as_completed(future_to_package):
result = future.result()
results.append(result)
# Update counts
if result["status"] == ResultStatus.TESTS_PASSED:
passed_count += 1
elif result["status"] in (
ResultStatus.TESTS_FAILED,
ResultStatus.COVERAGE_FAILED,
ResultStatus.INSTALL_FAILED,
):
failed_count += 1
else:
skipped_count += 1
# Get currently running packages and update display
running_packages = [
str(future_to_package[f].relative_to(repo_root))
for f in future_to_package
if not f.done()
]
live.update(
_generate_status_table(
len(packages_to_test),
len(results),
passed_count,
failed_count,
skipped_count,
running_packages,
)
)
# Print detailed results after completion
console.print("\n" + "=" * 60 + "\n")
console.print("Detailed Results:", style="bold")
console.print()
for result in results:
package: Path = result["package"]
package_name = package.relative_to(repo_root)
if result["status"] == ResultStatus.INSTALL_FAILED:
console.print(f"❗ Unable to build package {package_name}")
console.print(
_trim(debug, f"Error:\n{result['stderr']}"), style="warning"
)
elif result["status"] == ResultStatus.TESTS_PASSED:
if debug: # Only print passed tests in debug mode
console.print(f"✅ {package_name} succeeded in {result['time']}")
elif result["status"] == ResultStatus.UNSUPPORTED_PYTHON_VERSION:
if debug:
console.print(
f"⏭️ {package_name} skipped due to python version incompatibility"
)
console.print(
_trim(debug, f"Error:\n{result['stderr']}"), style="warning"
)
elif result["status"] == ResultStatus.NO_TESTS:
if debug:
console.print(f"⏭️ {package_name} skipped due to no tests")
console.print(
_trim(debug, f"Error:\n{result['stderr']}"), style="warning"
)
else:
console.print(f"❌ {package_name} failed")
console.print(
_trim(debug, f"Error:\n{result['stderr']}"), style="error"
)
console.print(
_trim(debug, f"Output:\n{result['stdout']}"), style="info"
)
# Print summary
failed = [
r["package"].relative_to(repo_root)
for r in results
if r["status"] in (ResultStatus.TESTS_FAILED, ResultStatus.COVERAGE_FAILED)
]
install_failed = [
r["package"].relative_to(repo_root)
for r in results
if r["status"] == ResultStatus.INSTALL_FAILED
]
skipped_no_tests = [
r["package"].relative_to(repo_root)
for r in results
if r["status"] == ResultStatus.NO_TESTS
and "package has no tests" in r["stderr"]
]
skipped_pyversion_incompatible = [
r["package"].relative_to(repo_root)
for r in results
if r["status"] == ResultStatus.UNSUPPORTED_PYTHON_VERSION
and "Not compatible with Python" in r["stderr"]
]
if skipped_pyversion_incompatible:
console.print(
f"\n{len(skipped_pyversion_incompatible)} packages were skipped due to Python version incompatibility:"
)
for p in skipped_pyversion_incompatible:
print(p)
if skipped_no_tests:
console.print(
f"\n{len(skipped_no_tests)} packages were skipped because they have no tests:"
)
for p in skipped_no_tests:
print(p)
if install_failed:
# Do not fail the CI if there was an installation problem: many packages cannot
# be installed in the runners, something we should either fix or ignore.
console.print(f"\n{len(install_failed)} packages could not be installed:")
for p in install_failed:
print(p)
if failed:
console.print(f"\n{len(failed)} packages had test failures:")
for p in failed:
console.print(p)
exit(1)
else:
console.print(
f"\nTests passed for {len(results) - len(skipped_no_tests) - len(skipped_pyversion_incompatible)} packages.",
style="green",
)
def _trim(debug: bool, msg: str):
lines = msg.split("\n")
if len(lines) > MAX_CONSOLE_PRINT_LINES and not debug:
lines = lines[:MAX_CONSOLE_PRINT_LINES]
lines.append(
"<-- llama-dev: output truncated, pass '--debug' to see the full log -->"
)
return "\n".join(lines)
def _uv_sync(
package_path: Path, env: dict[str, str]
) -> subprocess.CompletedProcess: # pragma: no cover
"""Run 'uv sync' on a package folder."""
return subprocess.run(
["uv", "sync"],
cwd=package_path,
text=True,
capture_output=True,
env=env,
)
def _uv_install_local(
package_path: Path, env: dict[str, str], install_local: set[Path]
) -> subprocess.CompletedProcess: # pragma: no cover
"""Run 'uv pip install -U <package_path1>, <package_path2>, ...' for locally changed packages."""
return subprocess.run(
["uv", "pip", "install", "-U", *install_local],
cwd=package_path,
text=True,
capture_output=True,
env=env,
)
def _pytest(
package_path: Path, env: dict[str, str], cov: bool
) -> subprocess.CompletedProcess:
pytest_cmd = [
"uv",
"run",
"--no-sync",
"--",
"pytest",
"-q",
"--disable-warnings",
"--disable-pytest-warnings",
]
if cov:
pytest_cmd += ["--cov=.", "--cov-report=xml"]
return subprocess.run(
pytest_cmd,
cwd=package_path,
text=True,
capture_output=True,
env=env,
timeout=300, # 5 minute timeout per package
)
def _diff_cover(
package_path: Path, env: dict[str, str], cov_fail_under: int, base_ref: str
) -> subprocess.CompletedProcess: # pragma: no cover
diff_cover_cmd = [
"uv",
"run",
"--",
"diff-cover",
"coverage.xml",
f"--fail-under={cov_fail_under}",
]
if base_ref:
diff_cover_cmd.append(f"--compare-branch={base_ref}")
return subprocess.run(
diff_cover_cmd,
cwd=package_path,
text=True,
capture_output=True,
env=env,
)
def _run_tests(
package_path: Path,
changed_packages: set[Path],
base_ref: str,
cov: bool,
cov_fail_under: int,
):
# Check Python version compatibility first
package_data = load_pyproject(package_path)
if not is_python_version_compatible(package_data):
return {
"package": package_path,
"status": ResultStatus.UNSUPPORTED_PYTHON_VERSION,
"stdout": "",
"stderr": f"Skipped: Not compatible with Python {sys.version_info.major}.{sys.version_info.minor}",
"time": "0.00s",
}
# Filter out packages that are not testable
if not package_has_tests(package_path):
return {
"package": package_path,
"status": ResultStatus.NO_TESTS,
"stdout": "",
"stderr": f"Skipped: package has no tests",
"time": "0.00s",
}
# Do not use the virtual environment calling llama-dev, if any
env = os.environ.copy()
if "VIRTUAL_ENV" in env:
del env["VIRTUAL_ENV"]
start = time.perf_counter()
# Install dependencies
result = _uv_sync(package_path, env)
if result.returncode != 0:
elapsed_time = time.perf_counter() - start
return {
"package": package_path,
"status": ResultStatus.INSTALL_FAILED,
"stdout": result.stdout,
"stderr": result.stderr,
"time": f"{elapsed_time:.2f}s",
}
# Install local copies of packages that have changed
dep_names = get_dep_names(package_data)
install_local: set[Path] = set()
for name in dep_names:
for ppath in changed_packages:
if ppath.name == name:
install_local.add(ppath)
if install_local:
result = _uv_install_local(package_path, env, install_local)
if result.returncode != 0:
elapsed_time = time.perf_counter() - start
return {
"package": package_path,
"status": ResultStatus.INSTALL_FAILED,
"stdout": result.stdout,
"stderr": result.stderr,
"time": f"{elapsed_time:.2f}s",
}
# Run pytest
try:
result = _pytest(package_path, env, cov)
except subprocess.TimeoutExpired:
elapsed_time = time.perf_counter() - start
return {
"package": package_path,
"status": ResultStatus.NO_TESTS,
"stdout": "",
"stderr": "Test execution timed out after 300s",
"time": f"{elapsed_time:.2f}s",
}
# Only fail if there are tests and they failed
if result.returncode != 0 and NO_TESTS_INDICATOR not in str(result.stdout).lower():
elapsed_time = time.perf_counter() - start
return {
"package": package_path,
"status": ResultStatus.TESTS_FAILED,
"stdout": result.stdout,
"stderr": result.stderr,
"time": f"{elapsed_time:.2f}s",
}
# Check coverage report
if cov_fail_under > 0:
result = _diff_cover(package_path, env, cov_fail_under, base_ref)
if result.returncode != 0:
elapsed_time = time.perf_counter() - start
return {
"package": package_path,
"status": ResultStatus.COVERAGE_FAILED,
"stdout": result.stdout,
"stderr": result.stderr,
"time": f"{elapsed_time:.2f}s",
}
# All done
elapsed_time = time.perf_counter() - start
return {
"package": package_path,
"status": ResultStatus.TESTS_PASSED,
"stdout": result.stdout,
"stderr": result.stderr,
"time": f"{elapsed_time:.2f}s",
}