chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""Shared Python workspace scripts."""
|
||||
@@ -0,0 +1,157 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Check code blocks in Markdown files for syntax errors."""
|
||||
|
||||
import argparse
|
||||
from enum import Enum
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import subprocess # nosec
|
||||
|
||||
from pygments import highlight # type: ignore
|
||||
from pygments.formatters import TerminalFormatter
|
||||
from pygments.lexers import PythonLexer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class Colors(str, Enum):
|
||||
CEND = "\33[0m"
|
||||
CRED = "\33[31m"
|
||||
CREDBG = "\33[41m"
|
||||
CGREEN = "\33[32m"
|
||||
CGREENBG = "\33[42m"
|
||||
CVIOLET = "\33[35m"
|
||||
CGREY = "\33[90m"
|
||||
|
||||
|
||||
def with_color(text: str, color: Colors) -> str:
|
||||
"""Prints a string with the specified color."""
|
||||
return f"{color.value}{text}{Colors.CEND.value}"
|
||||
|
||||
|
||||
def expand_file_patterns(patterns: list[str], skip_glob: bool = False) -> list[str]:
|
||||
"""Expand glob patterns to actual file paths."""
|
||||
all_files: list[str] = []
|
||||
for pattern in patterns:
|
||||
if skip_glob:
|
||||
# When skip_glob is True, treat patterns as literal file paths
|
||||
# Only include if it's a markdown file
|
||||
if pattern.endswith('.md'):
|
||||
matches = glob.glob(pattern, recursive=False)
|
||||
all_files.extend(matches)
|
||||
else:
|
||||
# Handle both relative and absolute paths with glob expansion
|
||||
matches = glob.glob(pattern, recursive=True)
|
||||
all_files.extend(matches)
|
||||
return sorted(set(all_files)) # Remove duplicates and sort
|
||||
|
||||
|
||||
def extract_python_code_blocks(markdown_file_path: str) -> list[tuple[str, int]]:
|
||||
"""Extract Python code blocks from a Markdown file."""
|
||||
with open(markdown_file_path, encoding="utf-8") as file:
|
||||
lines = file.readlines()
|
||||
|
||||
code_blocks: list[tuple[str, int]] = []
|
||||
in_code_block = False
|
||||
current_block: list[str] = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith("```python"):
|
||||
in_code_block = True
|
||||
current_block = []
|
||||
elif line.strip().startswith("```"):
|
||||
in_code_block = False
|
||||
code_blocks.append(("\n".join(current_block), i - len(current_block) + 1))
|
||||
elif in_code_block:
|
||||
current_block.append(line)
|
||||
|
||||
return code_blocks
|
||||
|
||||
|
||||
def check_code_blocks(markdown_file_paths: list[str], exclude_patterns: list[str] | None = None) -> None:
|
||||
"""Check Python code blocks in a Markdown file for syntax errors."""
|
||||
files_with_errors: list[str] = []
|
||||
exclude_patterns = exclude_patterns or []
|
||||
|
||||
for markdown_file_path in markdown_file_paths:
|
||||
# Skip files that match any exclude pattern
|
||||
if any(pattern in markdown_file_path for pattern in exclude_patterns):
|
||||
logger.info(f"Skipping {markdown_file_path} (matches exclude pattern)")
|
||||
continue
|
||||
code_blocks = extract_python_code_blocks(markdown_file_path)
|
||||
had_errors = False
|
||||
for code_block, line_no in code_blocks:
|
||||
markdown_file_path_with_line_no = f"{markdown_file_path}:{line_no}"
|
||||
logger.info("Checking a code block in %s...", markdown_file_path_with_line_no)
|
||||
|
||||
# Skip blocks that don't import agent_framework modules or import lab modules
|
||||
if (all(
|
||||
all(import_code not in code_block for import_code in [f"import {module}", f"from {module}"])
|
||||
for module in ["agent_framework"]
|
||||
) or "agent_framework.lab" in code_block):
|
||||
logger.info(f' {with_color("OK[ignored]", Colors.CGREENBG)}')
|
||||
continue
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
# Use the same rules as pyrightconfig.samples.json:
|
||||
# typeCheckingMode=off, only reportMissingImports and reportAttributeAccessIssue enabled.
|
||||
pyright_cfg = os.path.join(tmp_dir, "pyrightconfig.json")
|
||||
with open(pyright_cfg, "w") as cfg:
|
||||
cfg.write(
|
||||
'{"include":["."],"typeCheckingMode":"off",'
|
||||
'"reportMissingImports":"error","reportAttributeAccessIssue":"error"}'
|
||||
)
|
||||
tmp_file = os.path.join(tmp_dir, "snippet.py")
|
||||
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||
f.write(code_block)
|
||||
|
||||
result = subprocess.run(["uv", "run", "pyright", "-p", tmp_dir], capture_output=True, text=True, cwd=".") # nosec
|
||||
# Filter to only errors from our config rules; syntax-level errors
|
||||
# (top-level await, etc.) are expected in README documentation snippets.
|
||||
# Only flag reportMissingImports for agent_framework modules, not third-party packages.
|
||||
relevant_errors = [
|
||||
line for line in result.stdout.splitlines()
|
||||
if ("reportMissingImports" in line and "agent_framework" in line)
|
||||
or "reportAttributeAccessIssue" in line
|
||||
]
|
||||
if relevant_errors:
|
||||
highlighted_code = highlight(code_block, PythonLexer(), TerminalFormatter()) # type: ignore
|
||||
logger.info(
|
||||
f" {with_color('FAIL', Colors.CREDBG)}\n"
|
||||
f"{with_color('========================================================', Colors.CGREY)}\n"
|
||||
f"{with_color('Error', Colors.CRED)}: Pyright found issues in {with_color(markdown_file_path_with_line_no, Colors.CVIOLET)}:\n"
|
||||
f"{with_color('--------------------------------------------------------', Colors.CGREY)}\n"
|
||||
f"{highlighted_code}\n"
|
||||
f"{with_color('--------------------------------------------------------', Colors.CGREY)}\n"
|
||||
"\n"
|
||||
f"{with_color('pyright output:', Colors.CVIOLET)}\n"
|
||||
f"{with_color(result.stdout, Colors.CRED)}"
|
||||
f"{with_color('========================================================', Colors.CGREY)}\n"
|
||||
)
|
||||
had_errors = True
|
||||
else:
|
||||
logger.info(f" {with_color('OK', Colors.CGREENBG)}")
|
||||
|
||||
if had_errors:
|
||||
files_with_errors.append(markdown_file_path)
|
||||
|
||||
if files_with_errors:
|
||||
raise RuntimeError("Syntax errors found in the following files:\n" + "\n".join(files_with_errors))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Check code blocks in Markdown files for syntax errors.")
|
||||
# Argument is a list of markdown files containing glob patterns
|
||||
parser.add_argument("markdown_files", nargs="+", help="Markdown files to check (supports glob patterns).")
|
||||
parser.add_argument("--exclude", action="append", help="Exclude files containing this pattern.")
|
||||
parser.add_argument("--no-glob", action="store_true", help="Treat file arguments as literal paths (no glob expansion).")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Expand glob patterns to actual file paths (or skip if --no-glob)
|
||||
expanded_files = expand_file_patterns(args.markdown_files, skip_glob=args.no_glob)
|
||||
check_code_blocks(expanded_files, args.exclude)
|
||||
@@ -0,0 +1,95 @@
|
||||
# Dependency Scripts
|
||||
|
||||
This folder contains the Python workspace tooling for dependency maintenance:
|
||||
|
||||
- validating runtime dependency lower and upper bounds
|
||||
- refreshing exact dev dependency pins
|
||||
- writing dependency validation reports for local runs and workflows
|
||||
|
||||
Run the commands below from the `python/` directory.
|
||||
|
||||
## Files in this folder
|
||||
|
||||
- `validate_dependency_bounds.py`
|
||||
- Main entrypoint for dependency-bound workflows.
|
||||
- Supports `test`, `lower`, `upper`, and `both` modes.
|
||||
- `test` runs workspace-wide smoke validation at the lower and upper ends of the currently allowed ranges.
|
||||
- `lower`, `upper`, and `both` dispatch to the lower/upper optimizer implementations for one package.
|
||||
|
||||
- `upgrade_dev_dependencies.py`
|
||||
- Refreshes exact dev dependency pins across the root `pyproject.toml` and package `pyproject.toml` files.
|
||||
- Reuses the same version-selection logic as the upper-bound tooling so direct dev-tooling refreshes and dependency-range expansion stay consistent.
|
||||
|
||||
- `_dependency_bounds_lower_impl.py`
|
||||
- Package-scoped lower-bound optimizer.
|
||||
- Tries older dependency versions within the currently allowed line and keeps the oldest passing lower bound.
|
||||
- Writes `dependency-lower-bound-results.json` in this folder by default.
|
||||
|
||||
- `_dependency_bounds_upper_impl.py`
|
||||
- Package-scoped upper-bound optimizer.
|
||||
- Tries newer dependency versions within candidate lines and keeps the newest passing upper bound.
|
||||
- Also contains shared parsing/rewrite helpers reused by `upgrade_dev_dependencies.py`.
|
||||
- Writes `dependency-range-results.json` in this folder by default.
|
||||
|
||||
- `_dependency_bounds_runtime.py`
|
||||
- Shared helper used by the validators to build isolated `uv run` commands.
|
||||
- Reattaches the repo-wide toolchain (`ruff`, `pyright`, `pytest`, `poethepoet`, and related helpers) inside temporary environments so package tasks behave the same way they do in the workspace.
|
||||
|
||||
|
||||
## Common entrypoints
|
||||
|
||||
### Poe tasks
|
||||
|
||||
These are the normal user-facing entrypoints:
|
||||
|
||||
```bash
|
||||
uv run poe upgrade-dev-dependency-pins
|
||||
uv run poe upgrade-dev-dependencies
|
||||
uv run poe validate-dependency-bounds-test
|
||||
uv run poe validate-dependency-bounds-test --package core
|
||||
uv run poe validate-dependency-bounds-project --mode both --package core --dependency "<dependency-name>"
|
||||
```
|
||||
|
||||
- `upgrade-dev-dependency-pins` only refreshes exact dev pins in `pyproject.toml` files.
|
||||
- `upgrade-dev-dependencies` refreshes dev pins (using task above), runs `uv lock --upgrade`, reinstalls from the frozen lockfile, then runs `check`, `typing`, and `test`.
|
||||
- `validate-dependency-bounds-test` runs the repo-wide lower/upper smoke gate.
|
||||
- `validate-dependency-bounds-project` is the single package-scoped task; use `--mode lower`, `--mode upper`, or `--mode both` for the target package/dependency pair. Its `--package` argument defaults to `*`, and `--dependency` is optional, so automation can also use it for repo-wide upper-bound runs.
|
||||
|
||||
### GitHub Actions workflows
|
||||
|
||||
These workflows call the Poe tasks:
|
||||
|
||||
- `.github/workflows/python-dependency-range-validation.yml`
|
||||
- Trigger: `workflow_dispatch`
|
||||
- Runs `uv run poe validate-dependency-bounds-project --mode upper --package "*"`
|
||||
- Uploads `python/scripts/dependencies/dependency-range-results.json`
|
||||
- Creates issues for failing candidate versions and opens/updates a PR for passing range updates
|
||||
|
||||
- `.github/workflows/python-dev-dependency-upgrade.yml`
|
||||
- Trigger: `workflow_dispatch`
|
||||
- Runs `uv run poe upgrade-dev-dependencies`
|
||||
- Commits any resulting `pyproject.toml` / `uv.lock` changes and opens/updates a PR
|
||||
|
||||
### Direct module execution
|
||||
|
||||
These are useful for debugging or targeted manual runs:
|
||||
|
||||
```bash
|
||||
python -m scripts.dependencies.upgrade_dev_dependencies --dry-run --version-source lock
|
||||
python -m scripts.dependencies.validate_dependency_bounds --mode test --package core --dry-run
|
||||
python -m scripts.dependencies.validate_dependency_bounds --mode both --package core --dependencies openai --dry-run
|
||||
python -m scripts.dependencies._dependency_bounds_lower_impl --packages core --dependencies openai --dry-run
|
||||
python -m scripts.dependencies._dependency_bounds_upper_impl --packages core --dependencies openai --dry-run
|
||||
```
|
||||
|
||||
Use the direct lower/upper implementation modules mainly for debugging or development of the optimizers themselves. For normal usage, prefer the Poe tasks or `validate_dependency_bounds.py`.
|
||||
|
||||
## Generated report files
|
||||
|
||||
The validators write JSON reports into this folder:
|
||||
|
||||
- `dependency-bounds-test-results.json`
|
||||
- `dependency-lower-bound-results.json`
|
||||
- `dependency-range-results.json`
|
||||
|
||||
These report files are ignored by git.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# ruff: noqa: INP001
|
||||
|
||||
"""Shared runtime helpers for dependency-bound validation commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import tomli
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
|
||||
_TOOL_REQUIREMENT_NAMES = {
|
||||
"mypy",
|
||||
"poethepoet",
|
||||
"pyright",
|
||||
"pytest",
|
||||
"pytest-asyncio",
|
||||
"pytest-cov",
|
||||
"pytest-retry",
|
||||
"pytest-timeout",
|
||||
"pytest-xdist",
|
||||
"ruff",
|
||||
}
|
||||
|
||||
_ADDITIONAL_RUNTIME_REQUIREMENTS = (
|
||||
"graphviz",
|
||||
"opentelemetry-exporter-otlp-proto-grpc",
|
||||
"opentelemetry-exporter-otlp-proto-http",
|
||||
)
|
||||
|
||||
# Run pyright through the current interpreter so its import resolution matches the uv-created environment.
|
||||
_PYRIGHT_COMMAND = (
|
||||
"import subprocess, sys; "
|
||||
"raise SystemExit(subprocess.call([sys.executable, '-m', 'pyright', '--pythonpath', sys.executable]))"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def load_runtime_tool_requirements(workspace_root: str) -> list[str]:
|
||||
"""Load shared tool requirements used by package test and typing tasks."""
|
||||
workspace_path = Path(workspace_root)
|
||||
pyproject_path = workspace_path / "pyproject.toml"
|
||||
data = tomli.loads(pyproject_path.read_text())
|
||||
dev_requirements = data.get("dependency-groups", {}).get("dev", []) or []
|
||||
|
||||
# `uv run --isolated` starts from a clean environment, so the validator has to re-attach the
|
||||
# shared tooling that package-level poe tasks expect to find.
|
||||
runtime_requirements: list[str] = []
|
||||
for requirement in dev_requirements:
|
||||
if not isinstance(requirement, str):
|
||||
continue
|
||||
try:
|
||||
parsed = Requirement(requirement)
|
||||
except InvalidRequirement:
|
||||
continue
|
||||
if parsed.name.lower() in _TOOL_REQUIREMENT_NAMES:
|
||||
runtime_requirements.append(requirement)
|
||||
return runtime_requirements
|
||||
|
||||
|
||||
def extend_command_with_runtime_tools(command: list[str], workspace_root: Path) -> None:
|
||||
"""Append shared tooling requirements to a uv run command."""
|
||||
# Mirror the repo-wide test/lint toolchain inside the temporary environment before adding the task.
|
||||
for requirement in load_runtime_tool_requirements(str(workspace_root.resolve())):
|
||||
command.extend(["--with", requirement])
|
||||
for requirement in _ADDITIONAL_RUNTIME_REQUIREMENTS:
|
||||
command.extend(["--with", requirement])
|
||||
|
||||
|
||||
def extend_command_with_task(command: list[str], task_name: str) -> None:
|
||||
"""Append the command needed to execute one validation task."""
|
||||
if task_name == "pyright":
|
||||
command.extend(["python", "-c", _PYRIGHT_COMMAND])
|
||||
return
|
||||
|
||||
command.extend(["python", "-m", "poethepoet", task_name])
|
||||
|
||||
|
||||
def next_zero_major_minor_boundary(version_text: str) -> str:
|
||||
"""Return the exclusive upper bound for the next 0.x minor after the given version."""
|
||||
from packaging.version import Version
|
||||
|
||||
version = Version(version_text)
|
||||
return f"0.{version.minor + 1}.0"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# ruff: noqa: S603
|
||||
|
||||
"""Add a dependency to one workspace package selected by short name or path.
|
||||
|
||||
``uv add --package`` expects the published workspace distribution name, while
|
||||
the root Poe surface intentionally speaks in short repo package names such as
|
||||
``core``. This wrapper keeps the user-facing selector stable and translates it
|
||||
just before delegating to uv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import tomli
|
||||
from rich import print
|
||||
|
||||
from scripts.task_runner import discover_projects, project_filter_matches
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspacePackage:
|
||||
"""Workspace package metadata needed for `uv add --package`."""
|
||||
|
||||
short_name: str
|
||||
project_path: Path
|
||||
distribution_name: str
|
||||
|
||||
|
||||
def _load_distribution_name(pyproject_file: Path) -> str:
|
||||
with pyproject_file.open("rb") as f:
|
||||
data = tomli.load(f)
|
||||
return str(data.get("project", {}).get("name", "")).strip()
|
||||
|
||||
|
||||
def _discover_workspace_packages(workspace_root: Path) -> list[WorkspacePackage]:
|
||||
workspace_pyproject = workspace_root / "pyproject.toml"
|
||||
packages: list[WorkspacePackage] = []
|
||||
for project_path in sorted(discover_projects(workspace_pyproject), key=str):
|
||||
pyproject_file = workspace_root / project_path / "pyproject.toml"
|
||||
if not pyproject_file.exists():
|
||||
continue
|
||||
distribution_name = _load_distribution_name(pyproject_file)
|
||||
if not distribution_name:
|
||||
continue
|
||||
packages.append(
|
||||
WorkspacePackage(
|
||||
short_name=project_path.name,
|
||||
project_path=project_path,
|
||||
distribution_name=distribution_name,
|
||||
)
|
||||
)
|
||||
return packages
|
||||
|
||||
|
||||
def _resolve_workspace_package(workspace_root: Path, project_filter: str) -> WorkspacePackage:
|
||||
"""Resolve one workspace package from a user-facing selector.
|
||||
|
||||
The wrapper accepts the same short-name/path/distribution-name vocabulary as
|
||||
the other root tasks, but errors on ambiguous matches so dependency edits
|
||||
never hit the wrong package.
|
||||
"""
|
||||
matches = [
|
||||
package
|
||||
for package in _discover_workspace_packages(workspace_root)
|
||||
if project_filter_matches(package.project_path, project_filter, [package.short_name, package.distribution_name])
|
||||
]
|
||||
if not matches:
|
||||
raise SystemExit(f"No workspace package matched selector '{project_filter}'.")
|
||||
if len(matches) > 1:
|
||||
names = ", ".join(sorted(package.short_name for package in matches))
|
||||
raise SystemExit(
|
||||
f"Package selector '{project_filter}' matched multiple workspace packages: {names}. "
|
||||
"Use a more specific short name or path."
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Resolve a workspace project selector, then delegate to `uv add`."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add a dependency to a single workspace package selected by short name, path, or package name."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-P",
|
||||
"--package",
|
||||
dest="project",
|
||||
metavar="PACKAGE",
|
||||
required=True,
|
||||
help="Workspace package selector, such as `core`.",
|
||||
)
|
||||
# Keep the old long flag as a silent alias while downstream automation
|
||||
# finishes moving to the user-facing ``--package`` spelling.
|
||||
parser.add_argument("--project", dest="project", help=argparse.SUPPRESS)
|
||||
parser.add_argument("-D", "--dependency", required=True, help="Dependency specifier to add.")
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace_root = Path(__file__).resolve().parents[2]
|
||||
package = _resolve_workspace_package(workspace_root, args.project)
|
||||
print(
|
||||
f"[cyan]Adding {args.dependency} to {package.short_name} "
|
||||
f"({package.distribution_name})[/cyan]"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["uv", "add", "--package", package.distribution_name, args.dependency],
|
||||
cwd=workspace_root,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,192 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Refresh development dependency pins across the Python workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import tomli
|
||||
from rich import print
|
||||
|
||||
from scripts.dependencies._dependency_bounds_upper_impl import (
|
||||
VersionCatalog,
|
||||
_apply_package_replacements,
|
||||
_collect_development_pin_replacements,
|
||||
_load_lock_versions,
|
||||
)
|
||||
from scripts.task_runner import discover_projects
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceProject:
|
||||
"""Workspace project metadata used for development dependency pin refresh."""
|
||||
|
||||
name: str
|
||||
project_path: str
|
||||
pyproject_path: str
|
||||
pyproject_file: Path
|
||||
|
||||
|
||||
def _read_project_name(pyproject_file: Path) -> str:
|
||||
"""Return the normalized project name declared in a pyproject file."""
|
||||
with pyproject_file.open("rb") as f:
|
||||
data = tomli.load(f)
|
||||
|
||||
project = data.get("project", {}) or {}
|
||||
project_name = str(project.get("name", "")).strip()
|
||||
return project_name or pyproject_file.parent.name
|
||||
|
||||
|
||||
def _discover_workspace_projects(workspace_root: Path) -> list[WorkspaceProject]:
|
||||
"""Return the root project plus all package projects in the workspace."""
|
||||
workspace_pyproject = workspace_root / "pyproject.toml"
|
||||
projects = [
|
||||
WorkspaceProject(
|
||||
name=_read_project_name(workspace_pyproject),
|
||||
project_path=".",
|
||||
pyproject_path="pyproject.toml",
|
||||
pyproject_file=workspace_pyproject,
|
||||
)
|
||||
]
|
||||
|
||||
# The root project carries the repo-wide dev toolchain pins, while package pyprojects may
|
||||
# carry package-specific development groups. Refresh both surfaces in one pass so the
|
||||
# workspace stays internally consistent after a tooling bump.
|
||||
# Reuse the shared workspace discovery logic so this script stays aligned with the rest
|
||||
# of the repo-level task runners when packages are added or moved.
|
||||
for project in sorted(discover_projects(workspace_pyproject), key=lambda value: str(value)):
|
||||
pyproject_file = workspace_root / project / "pyproject.toml"
|
||||
if not pyproject_file.exists():
|
||||
continue
|
||||
|
||||
projects.append(
|
||||
WorkspaceProject(
|
||||
name=_read_project_name(pyproject_file),
|
||||
project_path=str(project),
|
||||
pyproject_path=str(project / "pyproject.toml"),
|
||||
pyproject_file=pyproject_file,
|
||||
)
|
||||
)
|
||||
|
||||
return projects
|
||||
|
||||
|
||||
def _normalize_filter(value: str) -> str:
|
||||
"""Normalize a package filter for matching project names and paths."""
|
||||
normalized = value.strip().strip("/").lower()
|
||||
return normalized or "."
|
||||
|
||||
|
||||
def _select_projects(projects: list[WorkspaceProject], package_filters: list[str] | None) -> list[WorkspaceProject]:
|
||||
"""Filter workspace projects by package name or workspace path if requested."""
|
||||
if not package_filters:
|
||||
return projects
|
||||
|
||||
normalized_filters = {_normalize_filter(value) for value in package_filters if value.strip()}
|
||||
selected: list[WorkspaceProject] = []
|
||||
for project in projects:
|
||||
normalized_path = _normalize_filter(project.project_path)
|
||||
candidates = {project.name.lower(), normalized_path}
|
||||
if normalized_path != ".":
|
||||
candidates.add(f"./{normalized_path}")
|
||||
|
||||
if candidates & normalized_filters:
|
||||
selected.append(project)
|
||||
|
||||
return selected
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Refresh exact development dependency pins in workspace pyproject files."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Refresh development dependency pins across the workspace pyproject.toml files. "
|
||||
"By default, resolves versions from PyPI and falls back to uv.lock when network access is unavailable."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--packages",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="Optional project filters by workspace path (for example packages/core) or package name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version-source",
|
||||
choices=["pypi", "lock"],
|
||||
default="pypi",
|
||||
help="Version source for selecting the newest development dependency pin.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print planned replacements without updating files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Show debug logging.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s")
|
||||
|
||||
workspace_root = Path(__file__).resolve().parents[2]
|
||||
lock_versions = _load_lock_versions(workspace_root)
|
||||
# Reuse the same version catalog as the bound-expansion tooling so development pin refreshes choose
|
||||
# versions with the same PyPI-vs-lock fallback behavior as the dependency validators.
|
||||
catalog = VersionCatalog(lock_versions=lock_versions, source=args.version_source)
|
||||
|
||||
selected_projects = _select_projects(
|
||||
_discover_workspace_projects(workspace_root),
|
||||
package_filters=args.packages,
|
||||
)
|
||||
logger.debug(
|
||||
"Selected projects for development dependency refresh: %s",
|
||||
[project.pyproject_path for project in selected_projects],
|
||||
)
|
||||
if not selected_projects:
|
||||
filters = ", ".join(args.packages or [])
|
||||
raise SystemExit(f"No matching workspace projects found for: {filters}")
|
||||
|
||||
updated_projects = 0
|
||||
updated_requirements = 0
|
||||
for project in selected_projects:
|
||||
# Keep the replacement logic centralized in the upper-bound helper so exact development pins are
|
||||
# formatted consistently regardless of whether we update them directly here or while
|
||||
# widening runtime dependency bounds.
|
||||
replacements = _collect_development_pin_replacements(project.pyproject_file, catalog=catalog)
|
||||
if not replacements:
|
||||
continue
|
||||
|
||||
updated_projects += 1
|
||||
updated_requirements += len(replacements)
|
||||
if args.dry_run:
|
||||
print(f"[yellow]Planned updates for {project.pyproject_path}[/yellow]")
|
||||
for original, replacement in replacements.items():
|
||||
print(f" - {original} -> {replacement}")
|
||||
continue
|
||||
|
||||
_apply_package_replacements(project.pyproject_file, replacements)
|
||||
print(
|
||||
f"[green]Updated {project.pyproject_path}[/green] "
|
||||
f"({project.name}) with {len(replacements)} development dependency pin refresh(es)."
|
||||
)
|
||||
|
||||
if updated_projects == 0:
|
||||
print("[green]No development dependency pin updates were needed.[/green]")
|
||||
return
|
||||
|
||||
action = "Would update" if args.dry_run else "Updated"
|
||||
print(
|
||||
f"[green]{action} {updated_requirements} development dependency pin(s) "
|
||||
f"across {updated_projects} workspace project(s).[/green]"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,503 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# ruff: noqa: S404, S603
|
||||
|
||||
"""Unified dependency-bound validation entrypoint.
|
||||
|
||||
Modes:
|
||||
- test: run workspace-wide compatibility gates at lower and upper resolutions.
|
||||
- lower: run lower-bound expansion for one package.
|
||||
- upper: run upper-bound expansion for one package.
|
||||
- both: run lower then upper expansion for one package.
|
||||
|
||||
Package filters intentionally reuse the root task selector semantics so the
|
||||
same short package names (for example ``core``) work in both contributor
|
||||
commands and direct debugging entrypoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import tomli
|
||||
from rich import print
|
||||
|
||||
from scripts.dependencies._dependency_bounds_runtime import (
|
||||
extend_command_with_runtime_tools,
|
||||
extend_command_with_task,
|
||||
)
|
||||
from scripts.dependencies._dependency_bounds_upper_impl import (
|
||||
_build_internal_graph,
|
||||
_build_workspace_package_map,
|
||||
_load_package_name,
|
||||
_resolve_internal_editables,
|
||||
)
|
||||
from scripts.task_runner import discover_projects, extract_poe_tasks, project_filter_matches
|
||||
|
||||
_LOWER_IMPL_MODULE = "scripts.dependencies._dependency_bounds_lower_impl"
|
||||
_UPPER_IMPL_MODULE = "scripts.dependencies._dependency_bounds_upper_impl"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PackageTestPlan:
|
||||
"""Workspace package settings needed for global test-mode validation."""
|
||||
|
||||
project_path: Path
|
||||
package_name: str
|
||||
dependency_groups: list[str]
|
||||
include_dev_extra: bool
|
||||
optional_extras: list[str]
|
||||
internal_editables: list[Path]
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _truncate_error(stdout: str, stderr: str, *, max_chars: int = 2000) -> str:
|
||||
combined = "\n".join(part for part in [stderr.strip(), stdout.strip()] if part)
|
||||
if len(combined) <= max_chars:
|
||||
return combined
|
||||
return f"...\n{combined[-max_chars:]}"
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=False))
|
||||
|
||||
|
||||
def _coerce_subprocess_output(output: str | bytes | None) -> str:
|
||||
if output is None:
|
||||
return ""
|
||||
if isinstance(output, bytes):
|
||||
return output.decode(errors="replace")
|
||||
return output
|
||||
|
||||
|
||||
def _build_test_plans(workspace_root: Path, package_filter: str | None) -> list[PackageTestPlan]:
|
||||
"""Build per-package test plans for the requested workspace selector."""
|
||||
workspace_pyproject = workspace_root / "pyproject.toml"
|
||||
package_map = _build_workspace_package_map(workspace_root)
|
||||
internal_graph = _build_internal_graph(workspace_root, package_map)
|
||||
|
||||
plans: list[PackageTestPlan] = []
|
||||
missing_tasks: list[str] = []
|
||||
for project_path in sorted(set(discover_projects(workspace_pyproject))):
|
||||
pyproject_file = workspace_root / project_path / "pyproject.toml"
|
||||
if not pyproject_file.exists():
|
||||
continue
|
||||
|
||||
package_name = _load_package_name(pyproject_file)
|
||||
# Reuse the shared matcher so dependency-bound test mode accepts the
|
||||
# same short names and legacy path-style selectors as the root Poe
|
||||
# commands.
|
||||
if (
|
||||
package_filter
|
||||
and package_filter != "*"
|
||||
and not project_filter_matches(project_path, package_filter, [package_name])
|
||||
):
|
||||
continue
|
||||
|
||||
available_tasks = extract_poe_tasks(pyproject_file)
|
||||
required_tasks = {"test", "pyright"}
|
||||
if not required_tasks.issubset(available_tasks):
|
||||
missing = sorted(required_tasks - available_tasks)
|
||||
missing_tasks.append(f"{project_path}: missing {', '.join(missing)}")
|
||||
continue
|
||||
with pyproject_file.open("rb") as f:
|
||||
package_config = tomli.load(f)
|
||||
project_section = package_config.get("project", {})
|
||||
optional_dependencies = project_section.get("optional-dependencies", {}) or {}
|
||||
dependency_groups = package_config.get("dependency-groups", {}) or {}
|
||||
|
||||
plans.append(
|
||||
PackageTestPlan(
|
||||
project_path=project_path,
|
||||
package_name=package_name,
|
||||
dependency_groups=sorted(dependency_groups),
|
||||
include_dev_extra="dev" in optional_dependencies,
|
||||
optional_extras=sorted(name for name in optional_dependencies if name not in {"all", "dev"}),
|
||||
internal_editables=_resolve_internal_editables(package_name, package_map, internal_graph),
|
||||
)
|
||||
)
|
||||
|
||||
if missing_tasks:
|
||||
details = "\n".join(missing_tasks)
|
||||
raise RuntimeError(f"Test mode requires test+pyright in every package.\n{details}")
|
||||
return plans
|
||||
|
||||
|
||||
def _run_package_tasks(
|
||||
workspace_root: Path,
|
||||
plan: PackageTestPlan,
|
||||
*,
|
||||
resolution: str,
|
||||
timeout_seconds: int,
|
||||
dry_run: bool,
|
||||
) -> tuple[bool, str | None]:
|
||||
# Test mode intentionally uses the same isolated uv execution model as the optimizer scripts
|
||||
# so the smoke gate matches the environment that lower/upper probes will run in.
|
||||
env = dict(os.environ)
|
||||
env["UV_PRERELEASE"] = "allow"
|
||||
# Avoid letting nested uv commands target the caller's active environment; validation should
|
||||
# stay inside uv's isolated throwaway environment instead of mutating `.venv`.
|
||||
env.pop("VIRTUAL_ENV", None)
|
||||
|
||||
for task_name in ("test", "pyright"):
|
||||
command = [
|
||||
"uv",
|
||||
"--no-progress",
|
||||
"--directory",
|
||||
str(workspace_root / plan.project_path),
|
||||
"run",
|
||||
"--isolated",
|
||||
"--resolution",
|
||||
resolution,
|
||||
"--prerelease",
|
||||
"allow",
|
||||
"--quiet",
|
||||
]
|
||||
extend_command_with_runtime_tools(command, workspace_root)
|
||||
for group_name in plan.dependency_groups:
|
||||
command.extend(["--group", group_name])
|
||||
if plan.include_dev_extra:
|
||||
command.extend(["--extra", "dev"])
|
||||
for extra_name in plan.optional_extras:
|
||||
command.extend(["--extra", extra_name])
|
||||
for editable_path in plan.internal_editables:
|
||||
command.extend(["--with-editable", str(editable_path)])
|
||||
extend_command_with_task(command, task_name)
|
||||
|
||||
if dry_run:
|
||||
print(f"[cyan]DRY RUN[/cyan] {' '.join(command)}")
|
||||
continue
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
error_message = _truncate_error(
|
||||
_coerce_subprocess_output(exc.stdout),
|
||||
_coerce_subprocess_output(exc.stderr),
|
||||
)
|
||||
if not error_message:
|
||||
error_message = "Process timed out without additional output."
|
||||
return (
|
||||
False,
|
||||
(
|
||||
f"Task '{task_name}' timed out for {plan.project_path} at resolution '{resolution}' "
|
||||
f"after {timeout_seconds} seconds.\n{error_message}"
|
||||
),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
error_message = _truncate_error(result.stdout, result.stderr)
|
||||
return (
|
||||
False,
|
||||
f"Task '{task_name}' failed for {plan.project_path} at resolution '{resolution}'.\n{error_message}",
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def _run_test_mode(
|
||||
*,
|
||||
workspace_root: Path,
|
||||
package_filter: str | None,
|
||||
timeout_seconds: int,
|
||||
dry_run: bool,
|
||||
output_json: Path,
|
||||
) -> int:
|
||||
plans = _build_test_plans(workspace_root, package_filter)
|
||||
if not plans:
|
||||
print("[yellow]No workspace packages found for test mode.[/yellow]")
|
||||
return 0
|
||||
|
||||
report: dict = {
|
||||
"started_at": _utc_now(),
|
||||
"mode": "test",
|
||||
"workspace_root": str(workspace_root),
|
||||
"dry_run": dry_run,
|
||||
"scenarios": [],
|
||||
"summary": {
|
||||
"packages_total": len(plans),
|
||||
"scenarios_passed": 0,
|
||||
"scenarios_failed": 0,
|
||||
},
|
||||
}
|
||||
_write_json(output_json, report)
|
||||
print(f"[cyan]Writing dependency-bounds test report to {output_json}[/cyan]")
|
||||
|
||||
# Smoke both ends of the allowed range: `lowest-direct` approximates lower-bound resolution,
|
||||
# while `highest` exercises the newest versions currently permitted by each package's specifiers.
|
||||
scenario_specs = [("lower", "lowest-direct"), ("upper", "highest")]
|
||||
for scenario_name, resolution in scenario_specs:
|
||||
print(f"[bold]Running {scenario_name} scenario ({resolution})[/bold]")
|
||||
scenario_result: dict = {
|
||||
"name": scenario_name,
|
||||
"resolution": resolution,
|
||||
"status": "passed",
|
||||
"packages": [],
|
||||
}
|
||||
for plan in plans:
|
||||
success, error = _run_package_tasks(
|
||||
workspace_root,
|
||||
plan,
|
||||
resolution=resolution,
|
||||
timeout_seconds=timeout_seconds,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
scenario_result["packages"].append({
|
||||
"project_path": str(plan.project_path),
|
||||
"package_name": plan.package_name,
|
||||
"status": "passed" if success else "failed",
|
||||
"error": error,
|
||||
})
|
||||
if success:
|
||||
print(f"[green]{plan.project_path}: {scenario_name} passed[/green]")
|
||||
continue
|
||||
|
||||
scenario_result["status"] = "failed"
|
||||
report["scenarios"].append(scenario_result)
|
||||
report["summary"]["scenarios_failed"] += 1
|
||||
report["updated_at"] = _utc_now()
|
||||
_write_json(output_json, report)
|
||||
print(f"[red]{plan.project_path}: {scenario_name} failed[/red]")
|
||||
print(f"[red]{error}[/red]")
|
||||
return 1
|
||||
|
||||
report["scenarios"].append(scenario_result)
|
||||
report["summary"]["scenarios_passed"] += 1
|
||||
report["updated_at"] = _utc_now()
|
||||
_write_json(output_json, report)
|
||||
|
||||
print("[bold green]Test mode completed successfully.[/bold green]")
|
||||
return 0
|
||||
|
||||
|
||||
def _build_optimizer_command(
|
||||
*,
|
||||
workspace_root: Path,
|
||||
module_name: str,
|
||||
package: str | None,
|
||||
dependencies: list[str] | None,
|
||||
parallelism: int,
|
||||
max_candidates: int,
|
||||
version_source: str,
|
||||
timeout_seconds: int,
|
||||
dry_run: bool,
|
||||
output_json: str | None,
|
||||
) -> list[str]:
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
module_name,
|
||||
"--parallelism",
|
||||
str(parallelism),
|
||||
"--max-candidates",
|
||||
str(max_candidates),
|
||||
"--version-source",
|
||||
version_source,
|
||||
"--timeout-seconds",
|
||||
str(timeout_seconds),
|
||||
]
|
||||
if package:
|
||||
command.extend(["--packages", package])
|
||||
if dependencies:
|
||||
command.extend(["--dependencies", *dependencies])
|
||||
if output_json:
|
||||
command.extend(["--output-json", output_json])
|
||||
if dry_run:
|
||||
command.append("--dry-run")
|
||||
return command
|
||||
|
||||
|
||||
def _run_optimizer_mode(
|
||||
*,
|
||||
workspace_root: Path,
|
||||
module_name: str,
|
||||
package: str | None,
|
||||
dependencies: list[str] | None,
|
||||
parallelism: int,
|
||||
max_candidates: int,
|
||||
version_source: str,
|
||||
timeout_seconds: int,
|
||||
dry_run: bool,
|
||||
output_json: str | None,
|
||||
) -> int:
|
||||
command = _build_optimizer_command(
|
||||
workspace_root=workspace_root,
|
||||
module_name=module_name,
|
||||
package=package,
|
||||
dependencies=dependencies,
|
||||
parallelism=parallelism,
|
||||
max_candidates=max_candidates,
|
||||
version_source=version_source,
|
||||
timeout_seconds=timeout_seconds,
|
||||
dry_run=dry_run,
|
||||
output_json=output_json,
|
||||
)
|
||||
print(f"[cyan]Running:[/cyan] {' '.join(command)}")
|
||||
result = subprocess.run(command, cwd=workspace_root, check=False)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def _with_suffix(path: str | None, suffix: str) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
value = Path(path)
|
||||
return str(value.with_name(f"{value.stem}-{suffix}{value.suffix}"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse arguments and run the requested dependency-bound mode."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Unified dependency-bound workflow. Use mode=test for workspace-wide lower+upper gates, "
|
||||
"or lower/upper/both for package-scoped or workspace-wide bound expansion."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
required=True,
|
||||
choices=("test", "lower", "upper", "both"),
|
||||
help="Execution mode: test (global) or lower/upper/both (package-scoped).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--package",
|
||||
default=None,
|
||||
help=(
|
||||
"Optional workspace package selector for all modes, such as `core`. "
|
||||
"Use '*' or omit it for the whole workspace."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dependencies",
|
||||
nargs="*",
|
||||
default=None,
|
||||
help="Optional dependency-name filters for lower/upper/both. Omit to process all matching dependencies.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parallelism",
|
||||
type=int,
|
||||
default=max(1, min(os.cpu_count() or 4, 8)),
|
||||
help="Parallelism forwarded to lower/upper optimizer scripts.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-candidates",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Maximum candidate bounds per dependency for lower/upper optimizer scripts (0 = no limit).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version-source",
|
||||
choices=("pypi", "lock"),
|
||||
default="pypi",
|
||||
help="Version source for candidate bounds.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout-seconds",
|
||||
type=int,
|
||||
default=1200,
|
||||
help="Timeout per task command execution.",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Do not execute mutating actions.")
|
||||
parser.add_argument(
|
||||
"--output-json",
|
||||
default=None,
|
||||
help="Optional output report path for lower/upper modes (both mode appends -lower/-upper).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-output-json",
|
||||
default="scripts/dependencies/dependency-bounds-test-results.json",
|
||||
help="Output report path for test mode.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace_root = Path(__file__).resolve().parents[2]
|
||||
normalized_package = None if args.package in {None, "", "*"} else args.package
|
||||
|
||||
if args.mode == "test":
|
||||
exit_code = _run_test_mode(
|
||||
workspace_root=workspace_root,
|
||||
package_filter=normalized_package,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
dry_run=args.dry_run,
|
||||
output_json=(workspace_root / args.test_output_json).resolve(),
|
||||
)
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
if args.mode == "lower":
|
||||
exit_code = _run_optimizer_mode(
|
||||
workspace_root=workspace_root,
|
||||
module_name=_LOWER_IMPL_MODULE,
|
||||
package=normalized_package,
|
||||
dependencies=args.dependencies,
|
||||
parallelism=args.parallelism,
|
||||
max_candidates=args.max_candidates,
|
||||
version_source=args.version_source,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
dry_run=args.dry_run,
|
||||
output_json=args.output_json,
|
||||
)
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
if args.mode == "upper":
|
||||
exit_code = _run_optimizer_mode(
|
||||
workspace_root=workspace_root,
|
||||
module_name=_UPPER_IMPL_MODULE,
|
||||
package=normalized_package,
|
||||
dependencies=args.dependencies,
|
||||
parallelism=args.parallelism,
|
||||
max_candidates=args.max_candidates,
|
||||
version_source=args.version_source,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
dry_run=args.dry_run,
|
||||
output_json=args.output_json,
|
||||
)
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
# Lower runs first so the subsequent upper pass starts from the widest lower bound that has
|
||||
# already been validated; when `--output-json` is supplied, each pass gets its own suffixed report.
|
||||
lower_exit = _run_optimizer_mode(
|
||||
workspace_root=workspace_root,
|
||||
module_name=_LOWER_IMPL_MODULE,
|
||||
package=normalized_package,
|
||||
dependencies=args.dependencies,
|
||||
parallelism=args.parallelism,
|
||||
max_candidates=args.max_candidates,
|
||||
version_source=args.version_source,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
dry_run=args.dry_run,
|
||||
output_json=_with_suffix(args.output_json, "lower"),
|
||||
)
|
||||
if lower_exit != 0:
|
||||
raise SystemExit(lower_exit)
|
||||
|
||||
upper_exit = _run_optimizer_mode(
|
||||
workspace_root=workspace_root,
|
||||
module_name=_UPPER_IMPL_MODULE,
|
||||
package=normalized_package,
|
||||
dependencies=args.dependencies,
|
||||
parallelism=args.parallelism,
|
||||
max_candidates=args.max_candidates,
|
||||
version_source=args.version_source,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
dry_run=args.dry_run,
|
||||
output_json=_with_suffix(args.output_json, "upper"),
|
||||
)
|
||||
raise SystemExit(upper_exit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Integration test report aggregation and trend generation.
|
||||
|
||||
Parses JUnit XML (``pytest.xml``) files produced by each CI job, merges
|
||||
them with historical data, and generates a markdown trend report showing
|
||||
per-test status across the last N runs.
|
||||
|
||||
Usage:
|
||||
uv run python -m scripts.integration_test_report <reports-dir> <history-file> <output-file>
|
||||
"""
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""CLI entry point for the integration test report tool.
|
||||
|
||||
Usage:
|
||||
uv run python -m scripts.integration_test_report <reports-dir> <history-file> <output-file>
|
||||
|
||||
Example (from python/ directory):
|
||||
uv run python -m scripts.integration_test_report \\
|
||||
../test-results/ \\
|
||||
integration-report-history.json \\
|
||||
integration-test-report.md
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from scripts.integration_test_report.aggregate import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,562 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Aggregate per-provider JUnit XML test results and generate a trend report.
|
||||
|
||||
Parses JUnit XML files produced by CI jobs — both ``pytest.xml`` (Python) and
|
||||
xunit v3 ``*.junit`` (dotnet) — merges them into a single run, combines
|
||||
with historical data, and generates a markdown trend table.
|
||||
|
||||
Usage (from CI):
|
||||
python aggregate.py <reports-dir> <history-file> <output-file>
|
||||
|
||||
The reports directory is expected to contain artifact subdirectories. Two
|
||||
layouts are supported:
|
||||
|
||||
- **Python (pytest):** ``test-results-<provider>/pytest.xml``
|
||||
- **Dotnet (xunit):** ``dotnet-test-results-<tfm>-<os>/*.junit``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
MAX_HISTORY = 5
|
||||
|
||||
STATUS_EMOJI = {
|
||||
"passed": "✅",
|
||||
"failed": "❌",
|
||||
"skipped": "⏭️",
|
||||
"xfailed": "⚠️",
|
||||
"error": "❌",
|
||||
}
|
||||
|
||||
|
||||
def _format_run_label(timestamp: str) -> str:
|
||||
"""Format a timestamp as a compact column label (e.g. '04-16 00:57')."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(timestamp)
|
||||
return dt.strftime("%m-%d %H:%M")
|
||||
except (ValueError, TypeError):
|
||||
return timestamp[:16]
|
||||
|
||||
|
||||
def _derive_provider(directory_name: str) -> str:
|
||||
"""Derive a provider label from a report directory name.
|
||||
|
||||
Handles both Python and dotnet naming conventions:
|
||||
- ``test-results-openai`` → ``OpenAI``
|
||||
- ``test-results-azure-openai`` → ``Azure OpenAI``
|
||||
- ``dotnet-test-results-net10.0-ubuntu-latest`` → ``net10.0 (ubuntu)``
|
||||
"""
|
||||
# Dotnet convention: dotnet-test-results-<framework>-<os>
|
||||
if directory_name.startswith("dotnet-test-results-"):
|
||||
raw = directory_name.replace("dotnet-test-results-", "")
|
||||
# e.g. "net10.0-ubuntu-latest" → framework="net10.0", os="ubuntu-latest"
|
||||
parts = raw.split("-", 1)
|
||||
framework = parts[0]
|
||||
os_label = parts[1].split("-")[0] if len(parts) > 1 else ""
|
||||
return f"{framework} ({os_label})" if os_label else framework
|
||||
|
||||
# Python convention: test-results-<provider>
|
||||
raw = directory_name.replace("test-results-", "")
|
||||
known = {
|
||||
"openai": "OpenAI",
|
||||
"azure-openai": "Azure OpenAI",
|
||||
"misc": "Misc (Anthropic, Ollama, MCP)",
|
||||
"functions": "Functions",
|
||||
"foundry": "Foundry",
|
||||
"cosmos": "Cosmos",
|
||||
"unit": "Unit",
|
||||
}
|
||||
if raw in known:
|
||||
return known[raw]
|
||||
parts = raw.split("-")
|
||||
return " ".join(p.capitalize() for p in parts)
|
||||
|
||||
|
||||
def _parse_junit_xml(xml_path: Path) -> list[dict[str, str]]:
|
||||
"""Parse a JUnit XML file and return a list of test result dicts.
|
||||
|
||||
Each dict has keys: ``nodeid``, ``status``, ``duration``, ``message``.
|
||||
"""
|
||||
results: list[dict[str, str]] = []
|
||||
try:
|
||||
tree = ET.parse(xml_path) # noqa: S314
|
||||
except ET.ParseError as exc:
|
||||
print(f"Warning: failed to parse JUnit XML report '{xml_path}': {exc}", file=sys.stderr)
|
||||
return results
|
||||
root = tree.getroot()
|
||||
|
||||
# Handle both <testsuites><testsuite>... and <testsuite>... layouts
|
||||
testcases: list[ET.Element] = []
|
||||
if root.tag == "testsuites":
|
||||
for suite in root.findall("testsuite"):
|
||||
testcases.extend(suite.findall("testcase"))
|
||||
elif root.tag == "testsuite":
|
||||
testcases = list(root.findall("testcase"))
|
||||
|
||||
for tc in testcases:
|
||||
classname = tc.get("classname", "")
|
||||
name = tc.get("name", "")
|
||||
duration = tc.get("time", "0")
|
||||
|
||||
# Use classname::name as a stable identifier.
|
||||
# pytest writes classname as the dotted module path (possibly including
|
||||
# a test class), e.g. "packages.openai.tests.openai.test_chat_client"
|
||||
# or "packages.openai.tests.openai.test_chat_client.TestClass".
|
||||
nodeid = f"{classname}::{name}" if classname else name
|
||||
|
||||
# Extract module/file name from classname for display context.
|
||||
# pytest writes classname as a dotted path. For tests inside a class
|
||||
# it appends the class name, e.g.:
|
||||
# "packages.foundry.tests.foundry.test_foundry_embedding_client.TestFoundryEmbeddingIntegration"
|
||||
# We want the file-level module: "test_foundry_embedding_client"
|
||||
#
|
||||
# xunit (dotnet) writes classname as the full C# type, e.g.:
|
||||
# "OpenAIChatCompletion.IntegrationTests.ChatCompletionTests"
|
||||
# We want the project prefix: "OpenAIChatCompletion"
|
||||
if classname:
|
||||
parts = classname.rsplit(".", 2)
|
||||
# If the last segment starts with uppercase it's a class name — take the one before it
|
||||
if len(parts) >= 2 and parts[-1][0:1].isupper():
|
||||
# For dotnet: if the penultimate part is "IntegrationTests" or "UnitTests",
|
||||
# use the part before that (the project name) instead
|
||||
if parts[-2] in ("IntegrationTests", "UnitTests") and len(parts) >= 3:
|
||||
# parts[0] may contain dots — take the last segment of it
|
||||
module = parts[0].rsplit(".", 1)[-1]
|
||||
else:
|
||||
module = parts[-2]
|
||||
else:
|
||||
module = parts[-1]
|
||||
else:
|
||||
module = ""
|
||||
|
||||
# Determine status from child elements
|
||||
failure = tc.find("failure")
|
||||
error = tc.find("error")
|
||||
skipped = tc.find("skipped")
|
||||
|
||||
if failure is not None:
|
||||
status = "failed"
|
||||
message = failure.get("message", "")
|
||||
elif error is not None:
|
||||
status = "error"
|
||||
message = error.get("message", "")
|
||||
elif skipped is not None:
|
||||
# pytest marks xfail as <skipped type="pytest.xfail">
|
||||
skip_type = skipped.get("type", "")
|
||||
status = "xfailed" if "xfail" in skip_type else "skipped"
|
||||
message = skipped.get("message", "")
|
||||
else:
|
||||
status = "passed"
|
||||
message = ""
|
||||
|
||||
results.append({
|
||||
"nodeid": nodeid,
|
||||
"status": status,
|
||||
"duration": duration,
|
||||
"message": message,
|
||||
"module": module,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _discover_xml_files(reports_dir: Path) -> list[tuple[str, Path]]:
|
||||
"""Discover JUnit XML test result files in artifact subdirectories.
|
||||
|
||||
Handles two directory layouts:
|
||||
- **Python (pytest):** ``test-results-<provider>/pytest.xml``
|
||||
- **Dotnet (xunit):** ``dotnet-test-results-<tfm>-<os>/*.junit``
|
||||
|
||||
Returns:
|
||||
List of ``(directory_name, xml_path)`` tuples.
|
||||
"""
|
||||
xml_files: list[tuple[str, Path]] = []
|
||||
if not reports_dir.is_dir():
|
||||
return xml_files
|
||||
|
||||
for subdir in sorted(reports_dir.iterdir()):
|
||||
if not subdir.is_dir():
|
||||
continue
|
||||
|
||||
# Python layout: single pytest.xml per artifact
|
||||
pytest_xml = subdir / "pytest.xml"
|
||||
if pytest_xml.exists():
|
||||
xml_files.append((subdir.name, pytest_xml))
|
||||
continue
|
||||
|
||||
# Dotnet layout: multiple *.junit files per artifact
|
||||
junit_files = sorted(subdir.rglob("*.junit"))
|
||||
for jf in junit_files:
|
||||
xml_files.append((subdir.name, jf))
|
||||
|
||||
# Fallback: any .xml file that looks like JUnit (not .trx, not cobertura)
|
||||
if not junit_files:
|
||||
for xf in sorted(subdir.rglob("*.xml")):
|
||||
if xf.suffix == ".xml" and not xf.name.endswith(".cobertura.xml"):
|
||||
xml_files.append((subdir.name, xf))
|
||||
|
||||
return xml_files
|
||||
|
||||
|
||||
def load_current_run(reports_dir: Path) -> dict[str, Any]:
|
||||
"""Load per-provider JUnit XML reports from the current CI run and merge.
|
||||
|
||||
Supports both pytest (Python) and xunit v3 (dotnet) JUnit XML formats.
|
||||
|
||||
Args:
|
||||
reports_dir: Directory containing artifact subdirectories with XML reports.
|
||||
|
||||
Returns:
|
||||
Merged run dict with ``timestamp``, ``summary``, ``results``.
|
||||
"""
|
||||
combined_results: dict[str, dict[str, str]] = {} # nodeid → {status, provider}
|
||||
|
||||
xml_files = _discover_xml_files(reports_dir)
|
||||
|
||||
if not xml_files:
|
||||
print(f"Warning: No JUnit XML files found in {reports_dir}")
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"summary": {
|
||||
"total": 0,
|
||||
"passed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
},
|
||||
"results": {},
|
||||
}
|
||||
|
||||
# Dotnet tests always run under multiple frameworks, so we always
|
||||
# qualify their keys with the provider to ensure deterministic,
|
||||
# stable keys across runs regardless of file parse order.
|
||||
is_dotnet = any(d.startswith("dotnet-test-results-") for d, _ in xml_files)
|
||||
|
||||
for dir_name, xml_file in xml_files:
|
||||
print(f" Loading: {xml_file}")
|
||||
provider = _derive_provider(dir_name)
|
||||
tests = _parse_junit_xml(xml_file)
|
||||
for test in tests:
|
||||
raw_id = test["nodeid"]
|
||||
key = f"{provider}::{raw_id}" if is_dotnet else raw_id
|
||||
|
||||
combined_results[key] = {
|
||||
"status": test["status"],
|
||||
"provider": provider,
|
||||
"module": test.get("module", ""),
|
||||
}
|
||||
|
||||
# Build per-provider summary counts so the report can show one row per
|
||||
# framework (dotnet) or per provider (Python).
|
||||
provider_counts: dict[str, dict[str, int]] = {}
|
||||
for r in combined_results.values():
|
||||
prov = r.get("provider", "Unknown")
|
||||
if prov not in provider_counts:
|
||||
provider_counts[prov] = {"total": 0, "passed": 0, "failed": 0, "skipped": 0}
|
||||
provider_counts[prov]["total"] += 1
|
||||
st = r["status"]
|
||||
if st == "passed":
|
||||
provider_counts[prov]["passed"] += 1
|
||||
elif st in ("failed", "error"):
|
||||
provider_counts[prov]["failed"] += 1
|
||||
elif st == "skipped":
|
||||
provider_counts[prov]["skipped"] += 1
|
||||
|
||||
# Overall summary (sum across all providers).
|
||||
statuses = [r["status"] for r in combined_results.values()]
|
||||
summary = {
|
||||
"total": len(statuses),
|
||||
"passed": statuses.count("passed"),
|
||||
"failed": statuses.count("failed") + statuses.count("error"),
|
||||
"skipped": statuses.count("skipped"),
|
||||
}
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"summary": summary,
|
||||
"provider_summaries": provider_counts,
|
||||
"results": combined_results,
|
||||
}
|
||||
|
||||
|
||||
def load_history(history_path: Path) -> list[dict[str, Any]]:
|
||||
"""Load previous run history from a cache file."""
|
||||
if history_path.exists():
|
||||
with open(history_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
runs = data.get("runs", [])
|
||||
print(f" Loaded {len(runs)} previous run(s) from history")
|
||||
return runs
|
||||
print(" No previous history found")
|
||||
return []
|
||||
|
||||
|
||||
def save_history(history_path: Path, runs: list[dict[str, Any]]) -> None:
|
||||
"""Save run history, keeping only the last ``MAX_HISTORY`` entries."""
|
||||
history_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
trimmed = runs[-MAX_HISTORY:]
|
||||
with open(history_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"runs": trimmed}, f, indent=2)
|
||||
print(f" Saved {len(trimmed)} run(s) to history")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _short_name(nodeid: str) -> str:
|
||||
"""Extract a short test name from a full nodeid.
|
||||
|
||||
``packages.openai.tests.openai.test_openai_chat_client::test_integration_options``
|
||||
→ ``test_integration_options``
|
||||
"""
|
||||
return nodeid.split("::")[-1] if "::" in nodeid else nodeid
|
||||
|
||||
|
||||
def generate_trend_report(runs: list[dict[str, Any]]) -> str:
|
||||
"""Generate a markdown trend report from run history."""
|
||||
lines = [
|
||||
"# 🔬 Integration Test Report",
|
||||
"",
|
||||
f"*Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}*",
|
||||
"",
|
||||
]
|
||||
|
||||
# Detect whether this is a dotnet report (provider-qualified keys).
|
||||
is_dotnet = False
|
||||
for run in runs:
|
||||
provider_sums = run.get("provider_summaries", {})
|
||||
if any(p.startswith("net") for p in provider_sums):
|
||||
is_dotnet = True
|
||||
break
|
||||
|
||||
if is_dotnet:
|
||||
_generate_dotnet_report(lines, runs)
|
||||
else:
|
||||
_generate_python_report(lines, runs)
|
||||
|
||||
lines.append("")
|
||||
lines.append("**Legend:** ✅ Passed · ❌ Failed · ⏭️ Skipped · ⚠️ Expected Failure (xfail) · N/A Not available")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_python_report(lines: list[str], runs: list[dict[str, Any]]) -> None:
|
||||
"""Generate the original single-table Python report format."""
|
||||
# --- Overall status table ---
|
||||
lines.append("## Overall Status (Last 5 Runs)")
|
||||
lines.append("")
|
||||
lines.append("| Run | Total | ✅ Passed | ❌ Failed | ⏭️ Skipped |")
|
||||
lines.append("|-----|-------|-----------|-----------|------------|")
|
||||
|
||||
for run in reversed(runs):
|
||||
s = run.get("summary", {})
|
||||
total = s.get("total", 0)
|
||||
label = _format_run_label(run["timestamp"])
|
||||
lines.append(
|
||||
f"| {label} "
|
||||
f"| {total} "
|
||||
f"| {s.get('passed', 0)}/{total} "
|
||||
f"| {s.get('failed', 0)}/{total} "
|
||||
f"| {s.get('skipped', 0)}/{total} |"
|
||||
)
|
||||
|
||||
for _ in range(MAX_HISTORY - len(runs)):
|
||||
lines.append("| N/A | N/A | N/A | N/A | N/A |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# --- Single per-test results table ---
|
||||
_generate_per_test_table(lines, runs, "## Per-Test Results")
|
||||
|
||||
|
||||
def _generate_dotnet_report(lines: list[str], runs: list[dict[str, Any]]) -> None:
|
||||
"""Generate per-framework tables for dotnet (net10.0, net472, etc.)."""
|
||||
# Collect all providers seen across all runs, sorted for stable ordering
|
||||
all_providers: set[str] = set()
|
||||
for run in runs:
|
||||
all_providers.update(run.get("provider_summaries", {}).keys())
|
||||
providers = sorted(all_providers)
|
||||
|
||||
for provider in providers:
|
||||
lines.append(f"## {provider}")
|
||||
lines.append("")
|
||||
|
||||
# --- Per-provider summary table ---
|
||||
lines.append("| Run | Total | ✅ Passed | ❌ Failed | ⏭️ Skipped |")
|
||||
lines.append("|-----|-------|-----------|-----------|------------|")
|
||||
|
||||
for run in reversed(runs):
|
||||
ps = run.get("provider_summaries", {}).get(provider, {})
|
||||
total = ps.get("total", 0)
|
||||
label = _format_run_label(run["timestamp"])
|
||||
if total == 0:
|
||||
lines.append(f"| {label} | N/A | N/A | N/A | N/A |")
|
||||
else:
|
||||
lines.append(
|
||||
f"| {label} "
|
||||
f"| {total} "
|
||||
f"| {ps.get('passed', 0)}/{total} "
|
||||
f"| {ps.get('failed', 0)}/{total} "
|
||||
f"| {ps.get('skipped', 0)}/{total} |"
|
||||
)
|
||||
|
||||
for _ in range(MAX_HISTORY - len(runs)):
|
||||
lines.append("| N/A | N/A | N/A | N/A | N/A |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# --- Per-test table filtered to this provider ---
|
||||
_generate_per_test_table(
|
||||
lines, runs,
|
||||
heading=None,
|
||||
provider_filter=provider,
|
||||
)
|
||||
|
||||
|
||||
def _generate_per_test_table(
|
||||
lines: list[str],
|
||||
runs: list[dict[str, Any]],
|
||||
heading: str | None = None,
|
||||
provider_filter: str | None = None,
|
||||
) -> None:
|
||||
"""Emit a per-test trend table, optionally filtered to a single provider."""
|
||||
if heading:
|
||||
lines.append(heading)
|
||||
lines.append("")
|
||||
|
||||
# Collect all test nodeids (and metadata) across all runs
|
||||
all_tests: dict[str, str] = {} # nodeid → provider
|
||||
all_modules: dict[str, str] = {} # nodeid → module
|
||||
for run in runs:
|
||||
for nodeid, info in run.get("results", {}).items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
prov = info.get("provider", "Unknown")
|
||||
if provider_filter and prov != provider_filter:
|
||||
continue
|
||||
module = info.get("module", "")
|
||||
all_tests[nodeid] = prov
|
||||
all_modules[nodeid] = module
|
||||
|
||||
if not all_tests:
|
||||
lines.append("*No test results available.*")
|
||||
lines.append("")
|
||||
return
|
||||
|
||||
# Build header
|
||||
if provider_filter:
|
||||
header = "| Test | File |"
|
||||
separator = "|------|------|"
|
||||
else:
|
||||
header = "| Test | File | Provider |"
|
||||
separator = "|------|------|----------|"
|
||||
for run in reversed(runs):
|
||||
label = _format_run_label(run["timestamp"])
|
||||
header += f" {label} |"
|
||||
separator += "------------|"
|
||||
for _ in range(MAX_HISTORY - len(runs)):
|
||||
header += " N/A |"
|
||||
separator += "-----|"
|
||||
|
||||
lines.append(header)
|
||||
lines.append(separator)
|
||||
|
||||
# Sort by module then test name
|
||||
for nodeid in sorted(all_tests, key=lambda n: (all_modules.get(n, ""), n)):
|
||||
module = all_modules.get(nodeid, "")
|
||||
short = _short_name(nodeid)
|
||||
if provider_filter:
|
||||
row = f"| `{short}` | `{module}` |"
|
||||
else:
|
||||
provider = all_tests[nodeid]
|
||||
row = f"| `{short}` | `{module}` | {provider} |"
|
||||
|
||||
for run in reversed(runs):
|
||||
result = run.get("results", {}).get(nodeid)
|
||||
if result is None:
|
||||
emoji = "N/A"
|
||||
else:
|
||||
status = result.get("status", "N/A") if isinstance(result, dict) else result
|
||||
emoji = STATUS_EMOJI.get(status, "❓")
|
||||
row += f" {emoji} |"
|
||||
|
||||
for _ in range(MAX_HISTORY - len(runs)):
|
||||
row += " N/A |"
|
||||
|
||||
lines.append(row)
|
||||
|
||||
lines.append("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 4:
|
||||
print("Usage: python aggregate.py <reports-dir> <history-file> <output-file>")
|
||||
return 1
|
||||
|
||||
reports_dir = Path(sys.argv[1])
|
||||
history_path = Path(sys.argv[2])
|
||||
output_path = Path(sys.argv[3])
|
||||
|
||||
print("Aggregating test results from JUnit XML...")
|
||||
|
||||
# Load current run's per-provider XML reports
|
||||
print(f"\nLoading reports from {reports_dir}:")
|
||||
current_run = load_current_run(reports_dir)
|
||||
s = current_run.get("summary", {})
|
||||
total = s.get("total", 0)
|
||||
print(
|
||||
f" Current run: {s.get('passed', 0)} passed, "
|
||||
f"{s.get('failed', 0)} failed, "
|
||||
f"{s.get('skipped', 0)} skipped "
|
||||
f"(total: {total})"
|
||||
)
|
||||
|
||||
# Load history and append current run (skip empty runs to avoid polluting trend)
|
||||
print(f"\nLoading history from {history_path}:")
|
||||
runs = load_history(history_path)
|
||||
if total > 0:
|
||||
runs.append(current_run)
|
||||
runs = runs[-MAX_HISTORY:]
|
||||
else:
|
||||
print(" Skipping history append (no test results in current run)")
|
||||
|
||||
# Save updated history
|
||||
print(f"\nSaving history to {history_path}:")
|
||||
save_history(history_path, runs)
|
||||
|
||||
# Generate trend report
|
||||
print("\nGenerating trend report...")
|
||||
report = generate_trend_report(runs)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(report, encoding="utf-8")
|
||||
print(f"Trend report written to {output_path}")
|
||||
|
||||
# Print the report to stdout for CI visibility
|
||||
print("\n" + "=" * 80)
|
||||
print(report)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Run a deterministic local streamable HTTP MCP server for integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8011
|
||||
DEFAULT_MOUNT_PATH = "/mcp"
|
||||
SERVER_NAME = "agent-framework-local-ci-mcp"
|
||||
AGENT_FRAMEWORK_DESCRIPTION = (
|
||||
"Microsoft Agent Framework is a multi-language framework for building, orchestrating, and deploying AI agents."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_mount_path(path: str) -> str:
|
||||
"""Normalize a configured mount path for the streamable HTTP endpoint."""
|
||||
normalized = path.strip() or DEFAULT_MOUNT_PATH
|
||||
if not normalized.startswith("/"):
|
||||
normalized = f"/{normalized}"
|
||||
return normalized.rstrip("/") or "/"
|
||||
|
||||
|
||||
def create_server(*, host: str, port: int, mount_path: str) -> FastMCP:
|
||||
"""Create the local MCP integration test server."""
|
||||
server = FastMCP(
|
||||
name=SERVER_NAME,
|
||||
instructions="Deterministic local MCP server used by Agent Framework integration tests.",
|
||||
host=host,
|
||||
port=port,
|
||||
streamable_http_path=mount_path,
|
||||
log_level="INFO",
|
||||
)
|
||||
|
||||
@server.custom_route("/healthz", methods=["GET"], include_in_schema=False)
|
||||
async def healthz(_request: Request) -> Response:
|
||||
"""Return a simple readiness response for CI health checks."""
|
||||
await asyncio.sleep(0)
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok",
|
||||
"name": SERVER_NAME,
|
||||
"mcp_path": mount_path,
|
||||
}
|
||||
)
|
||||
|
||||
@server.tool(
|
||||
name="search_agent_framework_docs",
|
||||
description="Return deterministic Agent Framework documentation text for MCP integration tests.",
|
||||
)
|
||||
def search_agent_framework_docs(query: str) -> str:
|
||||
"""Return a deterministic response for the MCP integration tests."""
|
||||
return (
|
||||
f"{AGENT_FRAMEWORK_DESCRIPTION}\n\n"
|
||||
f"Query: {query}\n"
|
||||
"This response came from the local streamable HTTP MCP integration test server."
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
"""Parse CLI arguments for the local MCP server."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", default=DEFAULT_HOST, help="Host interface to bind.")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to bind.")
|
||||
parser.add_argument(
|
||||
"--mount-path",
|
||||
default=DEFAULT_MOUNT_PATH,
|
||||
help="Mount path for the streamable HTTP MCP endpoint.",
|
||||
)
|
||||
return parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> None:
|
||||
"""Start the local MCP streamable HTTP server."""
|
||||
args = parse_args(argv)
|
||||
server = create_server(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
mount_path=_normalize_mount_path(args.mount_path),
|
||||
)
|
||||
server.run(transport="streamable-http")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Run task(s) only in packages that have changed files, in parallel by default."""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from rich import print
|
||||
from task_runner import build_work_items, discover_projects, run_tasks
|
||||
|
||||
# Tasks that need to run in all packages when core changes (type info propagates)
|
||||
TYPE_CHECK_TASKS = {"pyright", "mypy"}
|
||||
|
||||
|
||||
def get_changed_packages(
|
||||
projects: list[Path], changed_files: list[str], workspace_root: Path
|
||||
) -> tuple[set[Path], bool]:
|
||||
"""Determine which packages have changed files.
|
||||
|
||||
Returns:
|
||||
A tuple of (changed_packages, core_package_changed).
|
||||
"""
|
||||
changed_packages: set[Path] = set()
|
||||
core_package_changed = False
|
||||
|
||||
for file_path in changed_files:
|
||||
# Strip 'python/' prefix if present (when git diff is run from repo root)
|
||||
file_path_str = str(file_path)
|
||||
if file_path_str.startswith("python/"):
|
||||
file_path_str = file_path_str[7:] # Remove 'python/' prefix
|
||||
|
||||
# Convert to absolute path if relative
|
||||
abs_path = Path(file_path_str)
|
||||
if not abs_path.is_absolute():
|
||||
abs_path = workspace_root / file_path_str
|
||||
|
||||
# Check which package this file belongs to
|
||||
for project in projects:
|
||||
project_abs = workspace_root / project
|
||||
try:
|
||||
# Check if the file is within this project directory
|
||||
abs_path.relative_to(project_abs)
|
||||
changed_packages.add(project)
|
||||
if project == Path("packages/core"):
|
||||
core_package_changed = True
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return changed_packages, core_package_changed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run task(s) in changed packages, in parallel by default.")
|
||||
parser.add_argument("tasks", nargs="+", help="Task name(s) to run")
|
||||
parser.add_argument("--files", nargs="*", default=None, help="Changed files to determine which packages to run")
|
||||
parser.add_argument("--seq", action="store_true", help="Run sequentially instead of in parallel")
|
||||
args = parser.parse_args()
|
||||
|
||||
pyproject_file = Path(__file__).parent.parent / "pyproject.toml"
|
||||
workspace_root = pyproject_file.parent
|
||||
projects = discover_projects(pyproject_file)
|
||||
|
||||
# Determine which packages to check
|
||||
if not args.files or args.files == ["."]:
|
||||
task_list = ", ".join(args.tasks)
|
||||
print(f"[yellow]No specific files provided, running {task_list} in all packages[/yellow]")
|
||||
work_items = build_work_items(sorted(set(projects)), args.tasks)
|
||||
else:
|
||||
changed_packages, core_changed = get_changed_packages(projects, args.files, workspace_root)
|
||||
if not changed_packages:
|
||||
print("[yellow]No changes detected in any package, skipping[/yellow]")
|
||||
return
|
||||
|
||||
print(f"[cyan]Detected changes in packages: {', '.join(str(p) for p in sorted(changed_packages))}[/cyan]")
|
||||
|
||||
# File-local tasks (fmt, lint) only run in packages with actual changes.
|
||||
# Type-checking tasks (pyright, mypy) run in all packages when core changes,
|
||||
# because type changes in core propagate to downstream packages.
|
||||
local_tasks = [t for t in args.tasks if t not in TYPE_CHECK_TASKS]
|
||||
type_tasks = [t for t in args.tasks if t in TYPE_CHECK_TASKS]
|
||||
|
||||
work_items = build_work_items(sorted(changed_packages), local_tasks)
|
||||
if type_tasks:
|
||||
if core_changed:
|
||||
print("[yellow]Core package changed - type-checking all packages[/yellow]")
|
||||
work_items += build_work_items(sorted(set(projects)), type_tasks)
|
||||
else:
|
||||
work_items += build_work_items(sorted(changed_packages), type_tasks)
|
||||
|
||||
if not work_items:
|
||||
print("[yellow]No matching tasks found in any package[/yellow]")
|
||||
return
|
||||
|
||||
run_tasks(work_items, workspace_root, sequential=args.seq)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Run poe task(s) across all workspace packages, in parallel by default."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from task_runner import build_work_items, discover_projects, run_tasks
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run poe task(s) across all workspace packages, in parallel by default."
|
||||
)
|
||||
parser.add_argument("tasks", nargs="+", help="Task name(s) to run across packages")
|
||||
parser.add_argument("--seq", action="store_true", help="Run sequentially instead of in parallel")
|
||||
args = parser.parse_args()
|
||||
|
||||
pyproject_file = Path(__file__).parent.parent / "pyproject.toml"
|
||||
workspace_root = pyproject_file.parent
|
||||
projects = discover_projects(pyproject_file)
|
||||
|
||||
work_items = build_work_items(projects, args.tasks)
|
||||
run_tasks(work_items, workspace_root, sequential=args.seq)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,182 @@
|
||||
# Sample Validation System
|
||||
|
||||
An AI-powered workflow system for validating Python samples by discovering them, creating a nested batched workflow, and producing a report.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Sample Validation Workflow │
|
||||
│ (Sequential - 4 Executors) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────┼──────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Discover │ ──► │ Create Dynamic │ ──► │ Run Nested │
|
||||
│ Samples │ │ Batched Flow │ │ Workflow │
|
||||
└───────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
List[SampleInfo] WorkflowCreationResult ExecutionResult
|
||||
(workers + coordinator) │
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Generate Report │
|
||||
└─────────────────┘
|
||||
│
|
||||
▼
|
||||
Report
|
||||
```
|
||||
|
||||
### Nested Workflow Strategy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Nested Batched Workflow (coordinator + workers) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ WorkflowBuilder + fan-out/fan-in edges │ │
|
||||
│ │ - Coordinator dispatches tasks in bounded batches │ │
|
||||
│ │ - Worker executors run GitHub Copilot agents │ │
|
||||
│ │ - Collector aggregates per-sample RunResult messages │ │
|
||||
│ │ - Max in-flight workers set by --max-parallel-workers │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── sample_validation/
|
||||
│ ├── __init__.py # Package exports
|
||||
│ ├── README.md # This file
|
||||
│ ├── models.py # Data classes
|
||||
│ │ ├── SampleInfo # Discovered sample metadata
|
||||
│ │ ├── RunResult # Execution result
|
||||
│ │ └── Report # Final validation report
|
||||
│ ├── discovery.py # Sample discovery
|
||||
│ │ ├── discover_samples() # Finds all .py files
|
||||
│ │ └── DiscoverSamplesExecutor
|
||||
│ ├── report.py # Report generation
|
||||
│ │ ├── generate_report() # Create Report from results
|
||||
│ │ ├── save_report() # Write to markdown/JSON
|
||||
│ │ ├── print_summary() # Console output
|
||||
│ │ └── GenerateReportExecutor
|
||||
│ ├── create_dynamic_workflow_executor.py # Coordinator, workers, collector, CreateConcurrentValidationWorkflowExecutor
|
||||
│ ├── run_dynamic_validation_workflow_executor.py # RunDynamicValidationWorkflowExecutor
|
||||
│ └── workflow.py # Workflow assembly entrypoint
|
||||
├── __main__.py # CLI entry point
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required
|
||||
|
||||
- **agent-framework** - Core workflow and agent functionality
|
||||
- **agent-framework-github-copilot** - GitHub Copilot agent integration
|
||||
|
||||
### Optional
|
||||
|
||||
- `GITHUB_COPILOT_MODEL` to override default Copilot model selection.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
No required environment variables. Optional:
|
||||
|
||||
| Variable | Description | Required |
|
||||
| ------------------------ | --------------------------------- | -------- |
|
||||
| `GITHUB_COPILOT_MODEL` | Copilot model override | No |
|
||||
| `GITHUB_COPILOT_TIMEOUT` | Copilot request timeout (seconds) | No |
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Validate all samples
|
||||
uv run python -m sample_validation
|
||||
|
||||
# Validate specific subdirectory
|
||||
uv run python -m sample_validation --subdir 03-workflows
|
||||
|
||||
# Save reports to files
|
||||
uv run python -m sample_validation --save-report --output-dir ./reports
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```bash
|
||||
uv run python -m sample_validation [OPTIONS]
|
||||
|
||||
Options:
|
||||
--subdir TEXT Subdirectory to validate (relative to samples/)
|
||||
--output-dir TEXT Report output directory (default: ./_sample_validation/reports)
|
||||
--max-parallel-workers INT Max in-flight workers per batch (default: 10)
|
||||
--save-report Save reports to files
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Quick validation of a small directory
|
||||
uv run python -m sample_validation --subdir 03-workflows/_start-here
|
||||
|
||||
# Limit parallel workers for large sample sets
|
||||
uv run python -m sample_validation --subdir 02-agents --max-parallel-workers 8
|
||||
|
||||
# Save report artifacts
|
||||
uv run python -m sample_validation --save-report
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Discovery
|
||||
|
||||
Walks the samples directory and finds all `.py` files that:
|
||||
|
||||
- Don't start with `_` (excludes private files)
|
||||
- Aren't in `__pycache__` directories
|
||||
- Aren't in directories starting with `_` (excludes `_sample_validation`)
|
||||
|
||||
### 2. Dynamic Workflow Creation
|
||||
|
||||
Creates a nested workflow with:
|
||||
|
||||
- A coordinator executor
|
||||
- One worker executor per discovered sample
|
||||
- A collector executor
|
||||
|
||||
### 3. Nested Workflow Execution
|
||||
|
||||
The coordinator sends initial work to the first `max_parallel_workers` workers. As each worker finishes, it notifies
|
||||
the coordinator, which dispatches the next queued sample. Workers also send result items to the collector, which emits
|
||||
the final `ExecutionResult` once all samples are processed.
|
||||
|
||||
### 4. Report Generation
|
||||
|
||||
Produces:
|
||||
|
||||
- **Console summary** - Pass/fail counts with emoji indicators
|
||||
- **Markdown report** - Detailed results grouped by status
|
||||
- **JSON report** - Machine-readable for CI integration
|
||||
|
||||
## Report Status Codes
|
||||
|
||||
| Status | Label | Description |
|
||||
| ------------- | --------------- | ----------------------------------------- |
|
||||
| SUCCESS | [PASS] | Sample ran to completion with exit code 0 |
|
||||
| FAILURE | [FAIL] | Sample did not complete successfully (non-zero exit code) |
|
||||
| MISSING_SETUP | [MISSING_SETUP] | Sample skipped due to missing setup |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent output parsing errors
|
||||
|
||||
If an agent returns non-JSON content, that sample is marked as `FAILURE` with parser details in the report.
|
||||
|
||||
### GitHub Copilot authentication or CLI issues
|
||||
|
||||
Ensure GitHub Copilot is authenticated in your environment and the Copilot CLI is available.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Sample Validation System
|
||||
|
||||
A workflow-based system for validating Python samples by:
|
||||
1. Discovering all sample files
|
||||
2. Creating a dynamic nested concurrent workflow (one GitHub agent per sample)
|
||||
3. Running the nested workflow
|
||||
4. Generating a validation report
|
||||
|
||||
Usage:
|
||||
uv run python -m sample_validation
|
||||
uv run python -m sample_validation --subdir 01-get-started
|
||||
"""
|
||||
|
||||
from sample_validation.models import Report, RunResult, SampleInfo
|
||||
from sample_validation.workflow import create_validation_workflow
|
||||
|
||||
__all__ = [
|
||||
"SampleInfo",
|
||||
"RunResult",
|
||||
"Report",
|
||||
"create_validation_workflow",
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Sample Validation Script
|
||||
|
||||
Validates all Python samples in the samples directory using a workflow that:
|
||||
1. Discovers all sample files
|
||||
2. Builds a nested concurrent workflow with one GitHub agent per sample
|
||||
3. Runs the nested workflow
|
||||
4. Generates a validation report
|
||||
|
||||
Usage:
|
||||
uv run python -m sample_validation
|
||||
uv run python -m sample_validation --subdir 03-workflows
|
||||
uv run python -m sample_validation --output-dir ./reports
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Add the samples directory to the path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sample_validation.models import Report
|
||||
from sample_validation.report import save_report
|
||||
from sample_validation.workflow import ValidationConfig, create_validation_workflow
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate Python samples using a dynamic nested concurrent workflow",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
uv run python -m sample_validation # Validate all samples
|
||||
uv run python -m sample_validation --subdir 03-workflows # Validate only workflows
|
||||
uv run python -m sample_validation --output-dir ./reports # Save reports to custom dir
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--subdir",
|
||||
type=str,
|
||||
help="Validate samples only in the specified subdirectory (relative to samples/)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=str,
|
||||
default="./sample_validation/reports",
|
||||
help="Directory to save validation reports (default: ./sample_validation/reports)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--save-report",
|
||||
action="store_true",
|
||||
help="Save the validation report to files",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--max-parallel-workers",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Maximum number of samples to run in parallel per batch (default: 10)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--report-name",
|
||||
type=str,
|
||||
help="Custom name for the report files (without extension). If not provided, uses timestamp.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--exclude",
|
||||
nargs="+",
|
||||
type=str,
|
||||
help="Subdirectory paths to exclude (relative to the search directory set by --subdir)",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
"""Main entry point."""
|
||||
args = parse_arguments()
|
||||
|
||||
# Determine paths
|
||||
# Script is at python/scripts/sample_validation/__main__.py
|
||||
# python_root is python/, samples_dir is python/samples/
|
||||
python_root = Path(__file__).parent.parent.parent
|
||||
samples_dir = python_root / "samples"
|
||||
|
||||
print("=" * 80)
|
||||
print("SAMPLE VALIDATION WORKFLOW")
|
||||
print("=" * 80)
|
||||
print(f"Samples directory: {samples_dir}")
|
||||
print(f"Python root: {python_root}")
|
||||
|
||||
if os.environ.get("GITHUB_COPILOT_MODEL"):
|
||||
print(
|
||||
f"Using GitHub Copilot model override: {os.environ['GITHUB_COPILOT_MODEL']}"
|
||||
)
|
||||
|
||||
# Create validation config
|
||||
config = ValidationConfig(
|
||||
samples_dir=samples_dir,
|
||||
python_root=python_root,
|
||||
subdir=args.subdir,
|
||||
exclude=args.exclude,
|
||||
max_parallel_workers=max(1, args.max_parallel_workers),
|
||||
)
|
||||
|
||||
# Create and run the workflow
|
||||
workflow = create_validation_workflow(config)
|
||||
|
||||
print("\nStarting validation workflow...")
|
||||
print("-" * 80)
|
||||
|
||||
# Run the workflow
|
||||
run_start = time.perf_counter()
|
||||
try:
|
||||
events = await workflow.run("start")
|
||||
finally:
|
||||
run_duration = time.perf_counter() - run_start
|
||||
print(f"\nWorkflow run completed in {run_duration:.2f}s")
|
||||
|
||||
outputs = events.get_outputs()
|
||||
|
||||
if not outputs:
|
||||
print("\n[ERROR] Workflow did not produce any output")
|
||||
return 1
|
||||
|
||||
report: Report = outputs[0]
|
||||
|
||||
# Save report if requested
|
||||
if args.save_report:
|
||||
output_dir = samples_dir / args.output_dir
|
||||
md_path, json_path = save_report(report, output_dir, name=args.report_name)
|
||||
print("\nReports saved:")
|
||||
print(f" Markdown: {md_path}")
|
||||
print(f" JSON: {json_path}")
|
||||
|
||||
# Return appropriate exit code
|
||||
failed = report.failure_count + report.missing_setup_count
|
||||
return 1 if failed > 0 else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
@@ -0,0 +1,224 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Aggregate validation reports across runs and produce a trend report.
|
||||
|
||||
Reads JSON reports from individual validation jobs, combines them with
|
||||
cached history from previous runs, and produces a markdown trend report
|
||||
showing per-sample status over the last 5 runs.
|
||||
|
||||
Usage:
|
||||
python aggregate.py <reports-dir> <history-file> <output-file>
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
MAX_HISTORY = 5
|
||||
|
||||
STATUS_EMOJI = {
|
||||
"success": "✅",
|
||||
"failure": "❌",
|
||||
"missing_setup": "⚠️",
|
||||
}
|
||||
|
||||
|
||||
def _format_run_label(timestamp: str) -> str:
|
||||
"""Format a run timestamp as a compact column label (e.g. '03-24 18:05')."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(timestamp)
|
||||
return dt.strftime("%m-%d %H:%M")
|
||||
except (ValueError, TypeError):
|
||||
return timestamp[:16]
|
||||
|
||||
|
||||
def load_current_run(reports_dir: Path) -> dict[str, Any]:
|
||||
"""Load all JSON report files from the current run and merge them."""
|
||||
combined_results: dict[str, str] = {}
|
||||
total = success = failure = missing = 0
|
||||
|
||||
json_files = sorted(reports_dir.glob("*.json"))
|
||||
if not json_files:
|
||||
print(f"Warning: No JSON report files found in {reports_dir}")
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"summary": {
|
||||
"total_samples": 0,
|
||||
"success_count": 0,
|
||||
"failure_count": 0,
|
||||
"missing_setup_count": 0,
|
||||
},
|
||||
"results": {},
|
||||
}
|
||||
|
||||
for json_file in json_files:
|
||||
print(f" Loading report: {json_file.name}")
|
||||
with open(json_file, encoding="utf-8") as f:
|
||||
report = json.load(f)
|
||||
for result in report["results"]:
|
||||
combined_results[result["path"]] = result["status"]
|
||||
summary = report["summary"]
|
||||
total += summary["total_samples"]
|
||||
success += summary["success_count"]
|
||||
failure += summary["failure_count"]
|
||||
missing += summary["missing_setup_count"]
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"summary": {
|
||||
"total_samples": total,
|
||||
"success_count": success,
|
||||
"failure_count": failure,
|
||||
"missing_setup_count": missing,
|
||||
},
|
||||
"results": combined_results,
|
||||
}
|
||||
|
||||
|
||||
def load_history(history_path: Path) -> list[dict[str, Any]]:
|
||||
"""Load previous run history from cache."""
|
||||
if history_path.exists():
|
||||
with open(history_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
runs = data.get("runs", [])
|
||||
print(f" Loaded {len(runs)} previous run(s) from history")
|
||||
return runs
|
||||
print(" No previous history found")
|
||||
return []
|
||||
|
||||
|
||||
def save_history(history_path: Path, runs: list[dict[str, Any]]) -> None:
|
||||
"""Save run history, keeping only the last MAX_HISTORY entries."""
|
||||
history_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
trimmed = runs[-MAX_HISTORY:]
|
||||
with open(history_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"runs": trimmed}, f, indent=2)
|
||||
print(f" Saved {len(trimmed)} run(s) to history")
|
||||
|
||||
|
||||
def generate_trend_report(runs: list[dict[str, Any]]) -> str:
|
||||
"""Generate a markdown trend report from run history."""
|
||||
lines = [
|
||||
"# Sample Validation Trend Report",
|
||||
"",
|
||||
f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M UTC')}*",
|
||||
"",
|
||||
]
|
||||
|
||||
# --- Overall status table (most recent first) ---
|
||||
lines.append("## Overall Status (Last 5 Runs)")
|
||||
lines.append("")
|
||||
lines.append("| Run | Success | Failure | Missing Setup | Total |")
|
||||
lines.append("|-----|---------|---------|---------------|-------|")
|
||||
|
||||
for run in reversed(runs):
|
||||
s = run["summary"]
|
||||
label = _format_run_label(run["timestamp"])
|
||||
lines.append(
|
||||
f"| {label} | {s['success_count']}/{s['total_samples']} "
|
||||
f"| {s['failure_count']}/{s['total_samples']} "
|
||||
f"| {s['missing_setup_count']}/{s['total_samples']} "
|
||||
f"| {s['total_samples']} |"
|
||||
)
|
||||
|
||||
# Pad with N/A rows if fewer than 5 runs
|
||||
for _ in range(MAX_HISTORY - len(runs)):
|
||||
lines.append("| N/A | N/A | N/A | N/A | N/A |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# --- Per-sample results table ---
|
||||
lines.append("## Per-Sample Results")
|
||||
lines.append("")
|
||||
|
||||
# Collect all sample paths across all runs
|
||||
all_paths: set[str] = set()
|
||||
for run in runs:
|
||||
all_paths.update(run["results"].keys())
|
||||
|
||||
if not all_paths:
|
||||
lines.append("*No sample results available.*")
|
||||
return "\n".join(lines)
|
||||
|
||||
# Build header (most recent run first)
|
||||
header = "| Sample |"
|
||||
separator = "|--------|"
|
||||
for run in reversed(runs):
|
||||
label = _format_run_label(run["timestamp"])
|
||||
header += f" {label} |"
|
||||
separator += "------------|"
|
||||
for _ in range(MAX_HISTORY - len(runs)):
|
||||
header += " N/A |"
|
||||
separator += "-----|"
|
||||
|
||||
lines.append(header)
|
||||
lines.append(separator)
|
||||
|
||||
for path in sorted(all_paths):
|
||||
row = f"| `{path}` |"
|
||||
for run in reversed(runs):
|
||||
status = run["results"].get(path, "N/A")
|
||||
emoji = STATUS_EMOJI.get(status, "N/A")
|
||||
row += f" {emoji} |"
|
||||
for _ in range(MAX_HISTORY - len(runs)):
|
||||
row += " N/A |"
|
||||
lines.append(row)
|
||||
|
||||
lines.append("")
|
||||
lines.append("**Legend:** ✅ Success · ❌ Failure · ⚠️ Missing Setup · N/A Not available")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 4:
|
||||
print("Usage: python aggregate.py <reports-dir> <history-file> <output-file>")
|
||||
return 1
|
||||
|
||||
reports_dir = Path(sys.argv[1])
|
||||
history_path = Path(sys.argv[2])
|
||||
output_path = Path(sys.argv[3])
|
||||
|
||||
print("Aggregating validation results...")
|
||||
|
||||
# Load current run's reports
|
||||
print(f"\nLoading reports from {reports_dir}:")
|
||||
current_run = load_current_run(reports_dir)
|
||||
s = current_run["summary"]
|
||||
print(
|
||||
f" Current run: {s['success_count']} success, "
|
||||
f"{s['failure_count']} failure, "
|
||||
f"{s['missing_setup_count']} missing setup "
|
||||
f"(total: {s['total_samples']})"
|
||||
)
|
||||
|
||||
# Load history and append current run
|
||||
print(f"\nLoading history from {history_path}:")
|
||||
runs = load_history(history_path)
|
||||
runs.append(current_run)
|
||||
runs = runs[-MAX_HISTORY:]
|
||||
|
||||
# Save updated history
|
||||
print(f"\nSaving history to {history_path}:")
|
||||
save_history(history_path, runs)
|
||||
|
||||
# Generate trend report
|
||||
print("\nGenerating trend report...")
|
||||
report = generate_trend_report(runs)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(report, encoding="utf-8")
|
||||
print(f"Trend report written to {output_path}")
|
||||
|
||||
# Also print the report to stdout
|
||||
print("\n" + "=" * 80)
|
||||
print(report)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
WORKER_COMPLETED = "worker_completed"
|
||||
@@ -0,0 +1,321 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework import (
|
||||
Executor,
|
||||
Message,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
from copilot.session import PermissionHandler, PermissionRequestResult
|
||||
from copilot.session_events import PermissionRequest
|
||||
from pydantic import BaseModel
|
||||
from sample_validation.const import WORKER_COMPLETED
|
||||
from sample_validation.discovery import DiscoveryResult
|
||||
from sample_validation.models import (
|
||||
ExecutionResult,
|
||||
RunResult,
|
||||
RunStatus,
|
||||
SampleInfo,
|
||||
ValidationConfig,
|
||||
WorkflowCreationResult,
|
||||
)
|
||||
from typing_extensions import Never
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentResponseFormat(BaseModel):
|
||||
status: str
|
||||
output: str
|
||||
error: str
|
||||
fix: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoordinatorStart:
|
||||
samples: list[SampleInfo]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerFreed:
|
||||
worker_id: str
|
||||
|
||||
|
||||
class BatchCompletion:
|
||||
pass
|
||||
|
||||
|
||||
AgentInstruction = (
|
||||
"You are validating exactly one Python sample.\n"
|
||||
"Analyze the sample code and execute it as it is. Based on the execution result, determine "
|
||||
"if it runs successfully, fails, or is missing_setup. Use `missing_setup` if the sample reports "
|
||||
"missing required environment variables. The environment you're given should contain the necessary "
|
||||
"variables. Don't create new environment variables nor modify the sample code.\n"
|
||||
"Feel free to install any required dependencies if needed.\n"
|
||||
"The sample can be interactive. If it is interactive, respond to the sample when prompted "
|
||||
"based on your analysis of the code. You do not need to consult human on what to respond.\n"
|
||||
"If the sample fails, investigate the error and suggest a fix.\n"
|
||||
"Return ONLY valid JSON with this schema:\n"
|
||||
"{\n"
|
||||
' "status": "success|failure|missing_setup",\n'
|
||||
' "output": "short summary of the result and what you did if the sample was interactive",\n'
|
||||
' "error": "error details or empty string",\n'
|
||||
' "fix": "suggested code fix if the sample failed, otherwise empty string"\n'
|
||||
"}\n\n"
|
||||
)
|
||||
|
||||
|
||||
def parse_agent_json(text: str) -> AgentResponseFormat:
|
||||
"""Parse JSON object from an agent response."""
|
||||
stripped = text.strip()
|
||||
if stripped.startswith("{") and stripped.endswith("}"):
|
||||
return AgentResponseFormat.model_validate_json(stripped)
|
||||
|
||||
start = stripped.find("{")
|
||||
end = stripped.rfind("}")
|
||||
if start == -1 or end == -1 or end <= start:
|
||||
raise ValueError("No JSON object found in response")
|
||||
|
||||
return AgentResponseFormat.model_validate_json(stripped[start : end + 1])
|
||||
|
||||
|
||||
def status_from_text(value: str) -> RunStatus:
|
||||
"""Convert a string value to RunStatus with safe fallback."""
|
||||
normalized = value.strip().lower()
|
||||
for status in RunStatus:
|
||||
if status.value == normalized:
|
||||
return status
|
||||
return RunStatus.FAILURE
|
||||
|
||||
|
||||
def prompt_permission(
|
||||
request: PermissionRequest, context: dict[str, str]
|
||||
) -> PermissionRequestResult:
|
||||
"""Permission handler that always approves."""
|
||||
logger.debug(
|
||||
f"[Permission Request: {request.kind}] ({context})Automatically approved for sample validation."
|
||||
)
|
||||
return PermissionHandler.approve_all(request, context)
|
||||
|
||||
|
||||
class CustomAgentExecutor(Executor):
|
||||
"""Executor that runs a GitHub Copilot agent and returns its response.
|
||||
|
||||
We need the custom executor to wrap the agent call in a try/except to ensure that any exceptions are caught and
|
||||
returned as error responses, otherwise an exception in one agent could crash the entire workflow.
|
||||
"""
|
||||
|
||||
# Retry in case GitHub Copilot agent encounters transient errors unrelated to the sample execution.
|
||||
RETRY_COUNT = 1
|
||||
|
||||
def __init__(self, agent: GitHubCopilotAgent):
|
||||
super().__init__(id=agent.id)
|
||||
self.agent = agent
|
||||
self._session = agent.create_session()
|
||||
|
||||
@handler
|
||||
async def handle_task(
|
||||
self, sample: SampleInfo, ctx: WorkflowContext[WorkerFreed | RunResult]
|
||||
) -> None:
|
||||
"""Execute one sample task and notify collector + coordinator."""
|
||||
current_retry = 0
|
||||
while True:
|
||||
try:
|
||||
response = await self.agent.run(
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[f"Validate the following sample:\n\n{sample.relative_path}"],
|
||||
)
|
||||
],
|
||||
session=self._session,
|
||||
)
|
||||
result_payload = parse_agent_json(response.text)
|
||||
result = RunResult(
|
||||
sample=sample,
|
||||
status=status_from_text(result_payload.status),
|
||||
output=result_payload.output,
|
||||
error=result_payload.error,
|
||||
fix=result_payload.fix,
|
||||
)
|
||||
break
|
||||
except Exception as ex:
|
||||
if current_retry < self.RETRY_COUNT:
|
||||
logger.warning(
|
||||
f"Error executing agent {self.agent.id} (attempt {current_retry + 1}/{self.RETRY_COUNT}): {ex}. Retrying..."
|
||||
)
|
||||
try:
|
||||
current_retry += 1
|
||||
await self.agent.stop()
|
||||
await self.agent.start()
|
||||
self._session = self.agent.create_session() # Reset session for retry
|
||||
continue
|
||||
except Exception as restart_ex:
|
||||
logger.error(
|
||||
f"Error restarting agent {self.agent.id}: {restart_ex}. No more retries."
|
||||
)
|
||||
result = RunResult(
|
||||
sample=sample,
|
||||
status=RunStatus.FAILURE,
|
||||
output="",
|
||||
error=f"Original error: {ex}. Restart error: {restart_ex}",
|
||||
fix="",
|
||||
)
|
||||
break
|
||||
|
||||
logger.error(f"Error executing agent {self.agent.id}: {ex}")
|
||||
result = RunResult(
|
||||
sample=sample,
|
||||
status=RunStatus.FAILURE,
|
||||
output="",
|
||||
error=str(ex),
|
||||
fix="",
|
||||
)
|
||||
break
|
||||
|
||||
await ctx.send_message(result, target_id="collector")
|
||||
await ctx.send_message(WorkerFreed(worker_id=self.id), target_id="coordinator")
|
||||
|
||||
await ctx.add_event(WorkflowEvent(WORKER_COMPLETED, sample)) # type: ignore
|
||||
|
||||
|
||||
class BatchCoordinatorExecutor(Executor):
|
||||
"""Dispatch sample tasks to worker executors in bounded batches."""
|
||||
|
||||
def __init__(self, worker_ids: list[str], max_parallel_workers: int) -> None:
|
||||
super().__init__(id="coordinator")
|
||||
self._worker_ids = worker_ids
|
||||
self._max_parallel_workers = max(1, max_parallel_workers)
|
||||
self._pending: deque[SampleInfo] = deque()
|
||||
self._inflight: set[str] = set()
|
||||
|
||||
async def _assign_next(
|
||||
self, worker_id: str, ctx: WorkflowContext[SampleInfo | BatchCompletion]
|
||||
) -> None:
|
||||
if not self._pending:
|
||||
# No more samples to assign
|
||||
if not self._inflight:
|
||||
# All tasks are completed, notify collector and exit
|
||||
await ctx.send_message(BatchCompletion(), target_id="collector")
|
||||
return
|
||||
|
||||
sample = self._pending.popleft()
|
||||
self._inflight.add(worker_id)
|
||||
# Messages will get queued in the runner until the next superstep when all workers are freed,
|
||||
# thus achieving automatic batching without needing complex synchronization logic
|
||||
await ctx.send_message(sample, target_id=worker_id)
|
||||
|
||||
@handler
|
||||
async def on_start(
|
||||
self,
|
||||
start: CoordinatorStart,
|
||||
ctx: WorkflowContext[SampleInfo | BatchCompletion],
|
||||
) -> None:
|
||||
"""Initialize queue and dispatch first wave of tasks."""
|
||||
self._pending = deque(start.samples)
|
||||
self._inflight.clear()
|
||||
|
||||
for worker_id in self._worker_ids[: self._max_parallel_workers]:
|
||||
await self._assign_next(worker_id, ctx)
|
||||
|
||||
@handler
|
||||
async def on_worker_freed(
|
||||
self, freed: WorkerFreed, ctx: WorkflowContext[SampleInfo | BatchCompletion]
|
||||
) -> None:
|
||||
"""Dispatch next queued sample when a worker finishes."""
|
||||
self._inflight.discard(freed.worker_id)
|
||||
await self._assign_next(freed.worker_id, ctx)
|
||||
|
||||
|
||||
class CollectorExecutor(Executor):
|
||||
"""Collect per-sample results and emit the final execution result."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="collector")
|
||||
self._results: list[RunResult] = []
|
||||
|
||||
@handler
|
||||
async def on_all(
|
||||
self,
|
||||
batch_completion: BatchCompletion,
|
||||
ctx: WorkflowContext[Never, ExecutionResult],
|
||||
) -> None:
|
||||
"""Receive all results at once and emit Workflow Output."""
|
||||
await ctx.yield_output(ExecutionResult(results=self._results))
|
||||
|
||||
@handler
|
||||
async def on_item(self, item: RunResult, ctx: WorkflowContext) -> None:
|
||||
"""Record a result and emit output when all expected results arrive."""
|
||||
self._results.append(item)
|
||||
|
||||
|
||||
class CreateConcurrentValidationWorkflowExecutor(Executor):
|
||||
"""Executor that builds a nested concurrent workflow with one agent per sample."""
|
||||
|
||||
def __init__(self, config: ValidationConfig):
|
||||
super().__init__(id="create_dynamic_workflow")
|
||||
self.config = config
|
||||
|
||||
@handler
|
||||
async def create(
|
||||
self,
|
||||
discovery: DiscoveryResult,
|
||||
ctx: WorkflowContext[WorkflowCreationResult],
|
||||
) -> None:
|
||||
"""Create a nested workflow with a coordinator + worker fan-out/fan-in."""
|
||||
sample_count = len(discovery.samples)
|
||||
print(f"\nCreating nested batched workflow for {sample_count} samples...")
|
||||
|
||||
if sample_count == 0:
|
||||
await ctx.send_message(
|
||||
WorkflowCreationResult(samples=[], workflow=None, agents=[])
|
||||
)
|
||||
return
|
||||
|
||||
agents: list[GitHubCopilotAgent] = []
|
||||
workers: list[CustomAgentExecutor] = []
|
||||
|
||||
for index, sample in enumerate(discovery.samples, start=1):
|
||||
agent_id = f"sample_validator_{index}({sample.relative_path})"
|
||||
agent = GitHubCopilotAgent(
|
||||
id=agent_id,
|
||||
name=agent_id,
|
||||
instructions=AgentInstruction,
|
||||
default_options={
|
||||
"on_permission_request": prompt_permission,
|
||||
"timeout": 120,
|
||||
}, # type: ignore
|
||||
)
|
||||
agents.append(agent)
|
||||
|
||||
workers.append(CustomAgentExecutor(agent))
|
||||
|
||||
coordinator = BatchCoordinatorExecutor(
|
||||
worker_ids=[worker.id for worker in workers],
|
||||
max_parallel_workers=self.config.max_parallel_workers,
|
||||
)
|
||||
collector = CollectorExecutor()
|
||||
|
||||
nested_builder = WorkflowBuilder(start_executor=coordinator, output_from=[collector])
|
||||
nested_builder.add_edge(coordinator, collector)
|
||||
for worker in workers:
|
||||
nested_builder.add_edge(coordinator, worker)
|
||||
nested_builder.add_edge(worker, coordinator)
|
||||
nested_builder.add_edge(worker, collector)
|
||||
nested_workflow: Workflow = nested_builder.build()
|
||||
|
||||
await ctx.send_message(
|
||||
WorkflowCreationResult(
|
||||
samples=discovery.samples,
|
||||
workflow=nested_workflow,
|
||||
agents=agents,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Sample discovery module."""
|
||||
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Executor, WorkflowContext, handler
|
||||
|
||||
from sample_validation.models import DiscoveryResult, SampleInfo, ValidationConfig
|
||||
|
||||
|
||||
def _is_main_entrypoint_guard(test: ast.expr) -> bool:
|
||||
"""Check whether an expression is ``__name__ == '__main__'``."""
|
||||
if not isinstance(test, ast.Compare):
|
||||
return False
|
||||
|
||||
if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq):
|
||||
return False
|
||||
|
||||
if len(test.comparators) != 1:
|
||||
return False
|
||||
|
||||
left = test.left
|
||||
right = test.comparators[0]
|
||||
|
||||
return (
|
||||
isinstance(left, ast.Name)
|
||||
and left.id == "__name__"
|
||||
and isinstance(right, ast.Constant)
|
||||
and right.value == "__main__"
|
||||
) or (
|
||||
isinstance(right, ast.Name)
|
||||
and right.id == "__name__"
|
||||
and isinstance(left, ast.Constant)
|
||||
and left.value == "__main__"
|
||||
)
|
||||
|
||||
|
||||
def _has_main_entrypoint_guard(path: Path) -> bool:
|
||||
"""Check whether a Python file defines a top-level main entrypoint guard."""
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return any(
|
||||
isinstance(node, ast.If) and _is_main_entrypoint_guard(node.test)
|
||||
for node in tree.body
|
||||
)
|
||||
|
||||
|
||||
def discover_samples(
|
||||
samples_dir: Path,
|
||||
subdir: str | None = None,
|
||||
exclude: list[str] | None = None,
|
||||
) -> list[SampleInfo]:
|
||||
"""
|
||||
Find all Python sample files in the samples directory.
|
||||
|
||||
Args:
|
||||
samples_dir: Root samples directory
|
||||
subdir: Optional subdirectory to filter to
|
||||
exclude: Optional list of subdirectory paths (relative to the search directory) to exclude
|
||||
|
||||
Returns:
|
||||
List of SampleInfo objects for each discovered sample
|
||||
"""
|
||||
# Determine the search directory
|
||||
if subdir:
|
||||
search_dir = samples_dir / subdir
|
||||
if not search_dir.exists():
|
||||
print(f"Warning: Subdirectory '{subdir}' does not exist in {samples_dir}")
|
||||
return []
|
||||
else:
|
||||
search_dir = samples_dir
|
||||
|
||||
# Resolve excluded paths to absolute for reliable comparison
|
||||
exclude_paths = {(search_dir / exc).resolve() for exc in (exclude or [])}
|
||||
|
||||
python_files: list[Path] = []
|
||||
|
||||
# Walk through all subdirectories and find .py files
|
||||
for root, dirs, files in os.walk(search_dir):
|
||||
# Skip directories that start with _, __pycache__, or excluded paths
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if not d.startswith("_")
|
||||
and d != "__pycache__"
|
||||
and (Path(root) / d).resolve() not in exclude_paths
|
||||
]
|
||||
|
||||
for file in files:
|
||||
# Skip files that start with _ and include only scripts with a main entrypoint guard
|
||||
if file.endswith(".py") and not file.startswith("_"):
|
||||
file_path = Path(root) / file
|
||||
if _has_main_entrypoint_guard(file_path):
|
||||
python_files.append(file_path)
|
||||
|
||||
# Sort files for consistent execution order
|
||||
python_files = sorted(python_files)
|
||||
|
||||
# Convert to SampleInfo objects
|
||||
samples: list[SampleInfo] = []
|
||||
for path in python_files:
|
||||
try:
|
||||
samples.append(SampleInfo.from_path(path, samples_dir))
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read {path}: {e}")
|
||||
|
||||
return samples
|
||||
|
||||
|
||||
class DiscoverSamplesExecutor(Executor):
|
||||
"""Executor that discovers all samples in the samples directory."""
|
||||
|
||||
def __init__(self, config: ValidationConfig):
|
||||
super().__init__(id="discover_samples")
|
||||
self.config = config
|
||||
|
||||
@handler
|
||||
async def discover(self, _: str, ctx: WorkflowContext[DiscoveryResult]) -> None:
|
||||
"""Discover all Python samples."""
|
||||
print(f"🔍 Discovering samples in {self.config.samples_dir}")
|
||||
if self.config.subdir:
|
||||
print(f" Filtering to subdirectory: {self.config.subdir}")
|
||||
if self.config.exclude:
|
||||
print(f" Excluding: {', '.join(self.config.exclude)}")
|
||||
|
||||
samples = discover_samples(self.config.samples_dir, self.config.subdir, self.config.exclude)
|
||||
print(f" Found {len(samples)} samples")
|
||||
|
||||
await ctx.send_message(DiscoveryResult(samples=samples))
|
||||
@@ -0,0 +1,161 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Data models for the sample validation system."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Workflow
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationConfig:
|
||||
"""Configuration for the validation workflow."""
|
||||
|
||||
samples_dir: Path
|
||||
python_root: Path
|
||||
subdir: str | None = None
|
||||
exclude: list[str] | None = None
|
||||
max_parallel_workers: int = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class SampleInfo:
|
||||
"""Information about a discovered sample file."""
|
||||
|
||||
path: Path
|
||||
relative_path: str
|
||||
code: str
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, path: Path, samples_dir: Path) -> "SampleInfo":
|
||||
"""Create SampleInfo from a file path."""
|
||||
return cls(
|
||||
path=path,
|
||||
relative_path=str(path.relative_to(samples_dir)),
|
||||
code=path.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryResult:
|
||||
"""Result of sample discovery."""
|
||||
|
||||
samples: list[SampleInfo]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowCreationResult:
|
||||
"""Result of creating a nested per-sample concurrent workflow."""
|
||||
|
||||
samples: list[SampleInfo]
|
||||
workflow: Workflow | None
|
||||
agents: list[GitHubCopilotAgent]
|
||||
|
||||
|
||||
class RunStatus(Enum):
|
||||
"""Status of a sample run."""
|
||||
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
MISSING_SETUP = "missing_setup"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunResult:
|
||||
"""Result of running a single sample."""
|
||||
|
||||
sample: SampleInfo
|
||||
status: RunStatus
|
||||
output: str
|
||||
error: str
|
||||
fix: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
"""Result of sample execution."""
|
||||
|
||||
results: list[RunResult]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
"""Final validation report."""
|
||||
|
||||
timestamp: datetime
|
||||
total_samples: int
|
||||
success_count: int
|
||||
failure_count: int
|
||||
missing_setup_count: int
|
||||
results: list[RunResult] = field(default_factory=list) # type: ignore
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Generate a markdown report."""
|
||||
lines = [
|
||||
"# Sample Validation Report",
|
||||
"",
|
||||
f"**Generated:** {self.timestamp.isoformat()}",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
"| Metric | Count |",
|
||||
"|--------|-------|",
|
||||
f"| Total Samples | {self.total_samples} |",
|
||||
f"| [PASS] Success | {self.success_count} |",
|
||||
f"| [FAIL] Failure | {self.failure_count} |",
|
||||
f"| [MISSING_SETUP] Missing Setup | {self.missing_setup_count} |",
|
||||
"",
|
||||
"## Detailed Results",
|
||||
"",
|
||||
]
|
||||
|
||||
# Group by status
|
||||
for status in [RunStatus.FAILURE, RunStatus.MISSING_SETUP, RunStatus.SUCCESS]:
|
||||
status_results = [r for r in self.results if r.status == status]
|
||||
if not status_results:
|
||||
continue
|
||||
|
||||
status_label = {
|
||||
RunStatus.SUCCESS: "[PASS]",
|
||||
RunStatus.FAILURE: "[FAIL]",
|
||||
RunStatus.MISSING_SETUP: "[MISSING_SETUP]",
|
||||
}
|
||||
|
||||
lines.append(f"### {status_label[status]} {status.value.title()} ({len(status_results)})")
|
||||
lines.append("")
|
||||
|
||||
for result in status_results:
|
||||
lines.append(f"- **{result.sample.relative_path}**")
|
||||
if result.error:
|
||||
# Truncate long errors
|
||||
error_preview = result.error[:200] + "..." if len(result.error) > 200 else result.error
|
||||
lines.append(f" - Error: `{error_preview}`")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Convert report to dictionary for JSON serialization."""
|
||||
return {
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"summary": {
|
||||
"total_samples": self.total_samples,
|
||||
"success_count": self.success_count,
|
||||
"failure_count": self.failure_count,
|
||||
"missing_setup_count": self.missing_setup_count,
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"path": r.sample.relative_path,
|
||||
"status": r.status.value,
|
||||
"output": r.output,
|
||||
"error": r.error,
|
||||
"fix": r.fix,
|
||||
}
|
||||
for r in self.results
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Report generation for sample validation results."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Executor, WorkflowContext, handler
|
||||
from typing_extensions import Never
|
||||
|
||||
from sample_validation.models import ExecutionResult, Report, RunResult, RunStatus
|
||||
|
||||
|
||||
def generate_report(results: list[RunResult]) -> Report:
|
||||
"""
|
||||
Generate a validation report from run results.
|
||||
|
||||
Args:
|
||||
results: List of RunResult objects from sample execution
|
||||
|
||||
Returns:
|
||||
Report object with aggregated statistics
|
||||
"""
|
||||
# Sort results: failures, missing setup first, then successes
|
||||
status_priority = {
|
||||
RunStatus.FAILURE: 0,
|
||||
RunStatus.MISSING_SETUP: 1,
|
||||
RunStatus.SUCCESS: 2,
|
||||
}
|
||||
sorted_results = sorted(results, key=lambda r: status_priority[r.status])
|
||||
|
||||
return Report(
|
||||
timestamp=datetime.now(),
|
||||
total_samples=len(results),
|
||||
success_count=sum(1 for r in results if r.status == RunStatus.SUCCESS),
|
||||
failure_count=sum(1 for r in results if r.status == RunStatus.FAILURE),
|
||||
missing_setup_count=sum(1 for r in results if r.status == RunStatus.MISSING_SETUP),
|
||||
results=sorted_results,
|
||||
)
|
||||
|
||||
|
||||
def save_report(
|
||||
report: Report, output_dir: Path, name: str | None = None
|
||||
) -> tuple[Path, Path]:
|
||||
"""
|
||||
Save the report to markdown and JSON files.
|
||||
|
||||
Args:
|
||||
report: The report to save
|
||||
output_dir: Directory to save the report files
|
||||
name: Optional custom name for the report files (without extension)
|
||||
|
||||
Returns:
|
||||
Tuple of (markdown_path, json_path)
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if name:
|
||||
base_name = name
|
||||
else:
|
||||
timestamp_str = report.timestamp.strftime("%Y%m%d_%H%M%S")
|
||||
base_name = f"validation_report_{timestamp_str}"
|
||||
|
||||
# Save markdown
|
||||
md_path = output_dir / f"{base_name}.md"
|
||||
md_path.write_text(report.to_markdown(), encoding="utf-8")
|
||||
|
||||
# Save JSON
|
||||
json_path = output_dir / f"{base_name}.json"
|
||||
json_path.write_text(
|
||||
json.dumps(report.to_dict(), indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return md_path, json_path
|
||||
|
||||
|
||||
def print_summary(report: Report) -> None:
|
||||
"""Print a summary of the validation report to console."""
|
||||
print("\n" + "=" * 80)
|
||||
print("SAMPLE VALIDATION SUMMARY")
|
||||
print("=" * 80)
|
||||
|
||||
if (
|
||||
report.failure_count == 0
|
||||
and report.missing_setup_count == 0
|
||||
):
|
||||
print("[PASS] ALL SAMPLES PASSED!")
|
||||
else:
|
||||
print("[FAIL] SOME SAMPLES FAILED")
|
||||
|
||||
print(f"\nTotal samples: {report.total_samples}")
|
||||
print()
|
||||
print("Results:")
|
||||
print(f" [PASS] Success: {report.success_count}")
|
||||
print(f" [FAIL] Failure: {report.failure_count}")
|
||||
print(f" [MISSING_SETUP] Missing Setup: {report.missing_setup_count}")
|
||||
print("=" * 80)
|
||||
|
||||
# Print JSON output for GitHub Actions visibility
|
||||
print("\nJSON Report:")
|
||||
print(json.dumps(report.to_dict(), indent=2))
|
||||
|
||||
|
||||
class GenerateReportExecutor(Executor):
|
||||
"""Executor that generates the final validation report."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="generate_report")
|
||||
|
||||
@handler
|
||||
async def generate(
|
||||
self, execution: ExecutionResult, ctx: WorkflowContext[Never, Report]
|
||||
) -> None:
|
||||
"""Generate the validation report from fan-in results."""
|
||||
print("\nGenerating report...")
|
||||
|
||||
report = generate_report(execution.results)
|
||||
print_summary(report)
|
||||
|
||||
await ctx.yield_output(report)
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from agent_framework import Executor, WorkflowContext, handler
|
||||
from agent_framework.github import GitHubCopilotAgent
|
||||
|
||||
from sample_validation.const import WORKER_COMPLETED
|
||||
from sample_validation.create_dynamic_workflow_executor import CoordinatorStart
|
||||
from sample_validation.models import (
|
||||
ExecutionResult,
|
||||
RunResult,
|
||||
RunStatus,
|
||||
SampleInfo,
|
||||
WorkflowCreationResult,
|
||||
)
|
||||
|
||||
|
||||
async def stop_agents(agents: Sequence[GitHubCopilotAgent]) -> None:
|
||||
"""Stop all GitHub Copilot agents used by the nested workflow."""
|
||||
for agent in agents:
|
||||
try:
|
||||
await agent.stop()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
class RunDynamicValidationWorkflowExecutor(Executor):
|
||||
"""Executor that runs the nested workflow created in the previous step."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="run_dynamic_workflow")
|
||||
|
||||
@handler
|
||||
async def run(
|
||||
self, creation: WorkflowCreationResult, ctx: WorkflowContext[ExecutionResult]
|
||||
) -> None:
|
||||
"""Run the nested workflow and emit execution results."""
|
||||
if creation.workflow is None:
|
||||
await ctx.send_message(ExecutionResult(results=[]))
|
||||
return
|
||||
|
||||
print("\nRunning nested batched workflow...")
|
||||
print("-" * 80)
|
||||
|
||||
try:
|
||||
remaining_sample_counts = len(creation.samples)
|
||||
result: ExecutionResult | None = None
|
||||
async for event in creation.workflow.run(
|
||||
CoordinatorStart(samples=creation.samples), stream=True
|
||||
):
|
||||
if event.type == "output" and isinstance(event.data, ExecutionResult):
|
||||
result = event.data # type: ignore
|
||||
elif event.type == WORKER_COMPLETED and isinstance(
|
||||
event.data, SampleInfo
|
||||
): # type: ignore
|
||||
remaining_sample_counts -= 1
|
||||
print(
|
||||
f"Completed validation for sample: {event.data.relative_path:<80} | "
|
||||
f"Remaining: {remaining_sample_counts:>4}"
|
||||
)
|
||||
|
||||
if result is not None:
|
||||
await ctx.send_message(result)
|
||||
else:
|
||||
fallback_results = [
|
||||
RunResult(
|
||||
sample=sample,
|
||||
status=RunStatus.FAILURE,
|
||||
output="",
|
||||
error="Nested workflow did not return an ExecutionResult.",
|
||||
fix="",
|
||||
)
|
||||
for sample in creation.samples
|
||||
]
|
||||
await ctx.send_message(ExecutionResult(results=fallback_results))
|
||||
finally:
|
||||
await stop_agents(creation.agents)
|
||||
@@ -0,0 +1,47 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Sample Validation Workflow using Microsoft Agent Framework.
|
||||
|
||||
Workflow composition for sample validation.
|
||||
"""
|
||||
|
||||
from agent_framework import Workflow, WorkflowBuilder
|
||||
|
||||
from sample_validation.create_dynamic_workflow_executor import (
|
||||
CreateConcurrentValidationWorkflowExecutor,
|
||||
)
|
||||
from sample_validation.discovery import DiscoverSamplesExecutor, ValidationConfig
|
||||
from sample_validation.report import GenerateReportExecutor
|
||||
from sample_validation.run_dynamic_validation_workflow_executor import (
|
||||
RunDynamicValidationWorkflowExecutor,
|
||||
)
|
||||
|
||||
|
||||
def create_validation_workflow(
|
||||
config: ValidationConfig,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Create the sample validation workflow.
|
||||
|
||||
Args:
|
||||
config: Validation configuration
|
||||
|
||||
Returns:
|
||||
Configured Workflow instance
|
||||
"""
|
||||
discover = DiscoverSamplesExecutor(config)
|
||||
create_dynamic_workflow = CreateConcurrentValidationWorkflowExecutor(config)
|
||||
run_dynamic_workflow = RunDynamicValidationWorkflowExecutor()
|
||||
generate = GenerateReportExecutor()
|
||||
|
||||
return (
|
||||
WorkflowBuilder(start_executor=discover)
|
||||
.add_edge(discover, create_dynamic_workflow)
|
||||
.add_edge(create_dynamic_workflow, run_dynamic_workflow)
|
||||
.add_edge(run_dynamic_workflow, generate)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ValidationConfig", "create_validation_workflow"]
|
||||
@@ -0,0 +1,300 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shared utilities for running Poe tasks across workspace packages.
|
||||
|
||||
These helpers centralize workspace discovery, selector matching, and execution
|
||||
mode so the root task dispatcher and dependency tooling interpret package
|
||||
filters the same way.
|
||||
"""
|
||||
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
|
||||
# On Windows, stdout defaults to cp1252 under non-interactive callers (e.g.
|
||||
# prek / pre-commit hooks). Reconfigure to UTF-8 before importing rich so
|
||||
# unicode glyphs like ``\u2713`` don't raise ``UnicodeEncodeError``.
|
||||
if sys.platform == "win32":
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
reconfigure(encoding="utf-8")
|
||||
|
||||
import tomli
|
||||
from rich import print
|
||||
|
||||
|
||||
def discover_projects(workspace_pyproject_file: Path) -> list[Path]:
|
||||
"""Discover all workspace projects from pyproject.toml."""
|
||||
with workspace_pyproject_file.open("rb") as f:
|
||||
data = tomli.load(f)
|
||||
|
||||
projects = data["tool"]["uv"]["workspace"]["members"]
|
||||
exclude = data["tool"]["uv"]["workspace"].get("exclude", [])
|
||||
|
||||
all_projects: list[Path] = []
|
||||
for project in projects:
|
||||
if "*" in project:
|
||||
globbed = glob.glob(str(project), root_dir=workspace_pyproject_file.parent)
|
||||
globbed_paths = [Path(p) for p in globbed]
|
||||
all_projects.extend(globbed_paths)
|
||||
else:
|
||||
all_projects.append(Path(project))
|
||||
|
||||
for project in exclude:
|
||||
if "*" in project:
|
||||
globbed = glob.glob(str(project), root_dir=workspace_pyproject_file.parent)
|
||||
globbed_paths = [Path(p) for p in globbed]
|
||||
all_projects = [p for p in all_projects if p not in globbed_paths]
|
||||
else:
|
||||
all_projects = [p for p in all_projects if p != Path(project)]
|
||||
|
||||
return all_projects
|
||||
|
||||
|
||||
def extract_poe_tasks(file: Path) -> set[str]:
|
||||
"""Extract poe task names from a pyproject.toml file."""
|
||||
with file.open("rb") as f:
|
||||
data = tomli.load(f)
|
||||
|
||||
tasks = set(data.get("tool", {}).get("poe", {}).get("tasks", {}).keys())
|
||||
|
||||
# Check if there is an include too
|
||||
include: str | None = data.get("tool", {}).get("poe", {}).get("include", None)
|
||||
if include:
|
||||
include_file = file.parent / include
|
||||
if include_file.exists():
|
||||
tasks = tasks.union(extract_poe_tasks(include_file))
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def build_work_items(projects: list[Path], task_names: list[str]) -> list[tuple[Path, str]]:
|
||||
"""Build cross-product of (package, task) for packages that define the task."""
|
||||
work_items: list[tuple[Path, str]] = []
|
||||
for project in projects:
|
||||
available_tasks = extract_poe_tasks(project / "pyproject.toml")
|
||||
for task in task_names:
|
||||
if task in available_tasks:
|
||||
work_items.append((project, task))
|
||||
return work_items
|
||||
|
||||
|
||||
def normalize_project_filter(value: str) -> str:
|
||||
"""Normalize a user-supplied workspace selector.
|
||||
|
||||
Strip presentation differences so short names, relative paths, and globs can
|
||||
be compared with one matcher.
|
||||
"""
|
||||
normalized = value.strip().strip("/").replace("\\", "/")
|
||||
return normalized or "."
|
||||
|
||||
|
||||
def build_project_filter_candidates(project: Path | str, aliases: Sequence[str] = ()) -> set[str]:
|
||||
"""Return accepted selector values for one workspace project.
|
||||
|
||||
We accept the workspace path, short package name, and any supplied aliases
|
||||
so user-facing ``--package core`` stays stable even when underlying tools
|
||||
still need paths or distribution names.
|
||||
"""
|
||||
normalized_path = normalize_project_filter(str(project))
|
||||
candidates = {normalized_path}
|
||||
if normalized_path == ".":
|
||||
candidates.update({"./", "root"})
|
||||
else:
|
||||
# Accept bare short names like ``core`` alongside ``packages/core`` and
|
||||
# ``./packages/core`` so callers do not have to care which form a
|
||||
# downstream script prefers.
|
||||
path = Path(normalized_path)
|
||||
candidates.add(path.name)
|
||||
candidates.add(f"./{normalized_path}")
|
||||
|
||||
for alias in aliases:
|
||||
normalized_alias = normalize_project_filter(alias)
|
||||
if normalized_alias and normalized_alias != ".":
|
||||
candidates.add(normalized_alias)
|
||||
|
||||
return {candidate.lower() for candidate in candidates}
|
||||
|
||||
|
||||
def project_filter_matches(project: Path | str, pattern: str, aliases: Sequence[str] = ()) -> bool:
|
||||
"""Return whether a project matches a user-supplied selector or glob.
|
||||
|
||||
Matching happens against the normalized candidate set so CLI callers can use
|
||||
the same selector vocabulary everywhere.
|
||||
"""
|
||||
normalized_pattern = normalize_project_filter(pattern).lower()
|
||||
return any(
|
||||
fnmatch(candidate, normalized_pattern) for candidate in build_project_filter_candidates(project, aliases)
|
||||
)
|
||||
|
||||
|
||||
def _run_task_subprocess(
|
||||
project: Path,
|
||||
task: str,
|
||||
workspace_root: Path,
|
||||
task_args: Sequence[str] = (),
|
||||
) -> tuple[Path, str, int, str, str, float]:
|
||||
"""Run a single poe task in a project directory via subprocess."""
|
||||
start = time.monotonic()
|
||||
cwd = workspace_root / project
|
||||
result = subprocess.run(
|
||||
["uv", "run", "poe", task, *task_args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
elapsed = time.monotonic() - start
|
||||
return (project, task, result.returncode, result.stdout, result.stderr, elapsed)
|
||||
|
||||
|
||||
def _run_sequential(work_items: list[tuple[Path, str]], task_args: Sequence[str] = ()) -> None:
|
||||
"""Run tasks sequentially using in-process PoeThePoet (streaming output)."""
|
||||
from poethepoet.app import PoeThePoet
|
||||
|
||||
for project, task in work_items:
|
||||
print(f"Running task {task} in {project}")
|
||||
app = PoeThePoet(cwd=project)
|
||||
result = app(cli_args=[task, *task_args])
|
||||
if result:
|
||||
sys.exit(result)
|
||||
|
||||
|
||||
def _run_parallel(work_items: list[tuple[Path, str]], workspace_root: Path, task_args: Sequence[str] = ()) -> None:
|
||||
"""Run all (package x task) combinations in parallel via subprocesses."""
|
||||
max_workers = min(len(work_items), os.cpu_count() or 4)
|
||||
failures: list[tuple[Path, str, str, str]] = []
|
||||
completed = 0
|
||||
total = len(work_items)
|
||||
|
||||
print(f"[cyan]Running {total} task(s) in parallel (max {max_workers} workers)...[/cyan]")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(_run_task_subprocess, project, task, workspace_root, task_args): (project, task)
|
||||
for project, task in work_items
|
||||
}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
project, task, returncode, stdout, stderr, elapsed = future.result()
|
||||
completed += 1
|
||||
progress = f"[{completed}/{total}]"
|
||||
if returncode == 0:
|
||||
print(f" [green]✓[/green] {progress} {task} in {project} ({elapsed:.1f}s)")
|
||||
else:
|
||||
print(f" [red]✗[/red] {progress} {task} in {project} ({elapsed:.1f}s)")
|
||||
failures.append((project, task, stdout, stderr))
|
||||
|
||||
if failures:
|
||||
print(f"\n[red]{len(failures)} task(s) failed:[/red]")
|
||||
for project, task, stdout, stderr in failures:
|
||||
print(f"\n[red]{'=' * 60}[/red]")
|
||||
print(f"[red]FAILED: {task} in {project}[/red]")
|
||||
if stdout.strip():
|
||||
print(stdout)
|
||||
if stderr.strip():
|
||||
sys.stderr.write(stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n[green]All {total} task(s) passed ✓[/green]")
|
||||
|
||||
|
||||
def run_tasks(
|
||||
work_items: list[tuple[Path, str]],
|
||||
workspace_root: Path,
|
||||
*,
|
||||
sequential: bool = False,
|
||||
task_args: Sequence[str] = (),
|
||||
) -> None:
|
||||
"""Run work items either in parallel or sequentially.
|
||||
|
||||
Single items use in-process PoeThePoet for streaming output.
|
||||
Multiple items use parallel subprocesses by default.
|
||||
"""
|
||||
if not work_items:
|
||||
print("[yellow]No matching tasks found in any package[/yellow]")
|
||||
return
|
||||
|
||||
if sequential or len(work_items) == 1:
|
||||
_run_sequential(work_items, task_args)
|
||||
else:
|
||||
_run_parallel(work_items, workspace_root, task_args)
|
||||
|
||||
|
||||
def _run_command_subprocess(
|
||||
label: str,
|
||||
command: Sequence[str],
|
||||
workspace_root: Path,
|
||||
) -> tuple[str, int, str, str, float]:
|
||||
"""Run a single labelled command in ``workspace_root`` and capture its output."""
|
||||
start = time.monotonic()
|
||||
result = subprocess.run(command, cwd=workspace_root, capture_output=True, text=True)
|
||||
elapsed = time.monotonic() - start
|
||||
return (label, result.returncode, result.stdout, result.stderr, elapsed)
|
||||
|
||||
|
||||
def run_command_items(
|
||||
command_items: list[tuple[str, Sequence[str]]],
|
||||
workspace_root: Path,
|
||||
*,
|
||||
sequential: bool = False,
|
||||
) -> None:
|
||||
"""Run labelled commands using the same model as :func:`run_tasks`.
|
||||
|
||||
A single command streams its output live; multiple commands run in parallel
|
||||
subprocesses with captured output and a ``✓``/``✗`` summary, mirroring the
|
||||
pyright fan-out presentation.
|
||||
"""
|
||||
if not command_items:
|
||||
print("[yellow]No commands to run[/yellow]")
|
||||
return
|
||||
|
||||
if sequential or len(command_items) == 1:
|
||||
for label, command in command_items:
|
||||
print(f"[cyan]>> {label}[/cyan]")
|
||||
result = subprocess.run(command, cwd=workspace_root)
|
||||
if result.returncode:
|
||||
sys.exit(result.returncode)
|
||||
return
|
||||
|
||||
max_workers = min(len(command_items), os.cpu_count() or 4)
|
||||
failures: list[tuple[str, str, str]] = []
|
||||
completed = 0
|
||||
total = len(command_items)
|
||||
|
||||
print(f"[cyan]Running {total} task(s) in parallel (max {max_workers} workers)...[/cyan]")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(_run_command_subprocess, label, command, workspace_root): label
|
||||
for label, command in command_items
|
||||
}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
label, returncode, stdout, stderr, elapsed = future.result()
|
||||
completed += 1
|
||||
progress = f"[{completed}/{total}]"
|
||||
if returncode == 0:
|
||||
print(f" [green]✓[/green] {progress} {label} ({elapsed:.1f}s)")
|
||||
else:
|
||||
print(f" [red]✗[/red] {progress} {label} ({elapsed:.1f}s)")
|
||||
failures.append((label, stdout, stderr))
|
||||
|
||||
if failures:
|
||||
print(f"\n[red]{len(failures)} task(s) failed:[/red]")
|
||||
for label, stdout, stderr in failures:
|
||||
print(f"\n[red]{'=' * 60}[/red]")
|
||||
print(f"[red]FAILED: {label}[/red]")
|
||||
if stdout.strip():
|
||||
print(stdout)
|
||||
if stderr.strip():
|
||||
sys.stderr.write(stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n[green]All {total} task(s) passed ✓[/green]")
|
||||
@@ -0,0 +1,828 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Dispatch contributor-facing workspace tasks with consistent scope flags.
|
||||
|
||||
This script is the single root-task entrypoint used by ``python/pyproject.toml``.
|
||||
It keeps selector semantics, aggregate-vs-fan-out behaviour, and compatibility
|
||||
aliases in one place so docs and automation can share the same command surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import tomli
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import Version
|
||||
from rich import print
|
||||
from task_runner import (
|
||||
build_work_items,
|
||||
discover_projects,
|
||||
project_filter_matches,
|
||||
run_command_items,
|
||||
run_tasks,
|
||||
)
|
||||
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent
|
||||
WORKSPACE_PYPROJECT = WORKSPACE_ROOT / "pyproject.toml"
|
||||
CURRENT_PYTHON = Version(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
||||
SAMPLE_EXCLUDES = "samples/autogen-migration,samples/semantic-kernel-migration"
|
||||
SAMPLE_RUFF_IGNORE = "E501,ASYNC,B901,TD002"
|
||||
MARKDOWN_EXCLUDES = [
|
||||
"cookiecutter-agent-framework-lab",
|
||||
"tau2",
|
||||
"packages/devui/frontend",
|
||||
"context_providers/azure_ai_search",
|
||||
]
|
||||
DEFAULT_AGGREGATE_TEST_EXCLUDES = {"devui", "lab"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceProject:
|
||||
"""Metadata about a workspace package."""
|
||||
|
||||
path: Path
|
||||
name: str
|
||||
distribution_name: str
|
||||
requires_python: str | None
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]:
|
||||
"""Parse the workspace command and return any pass-through arguments."""
|
||||
parser = argparse.ArgumentParser(description="Dispatch workspace Poe tasks with consistent scope flags.")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
def add_project_option(command: argparse.ArgumentParser) -> None:
|
||||
command.add_argument(
|
||||
"-P",
|
||||
"--package",
|
||||
dest="project",
|
||||
default="*",
|
||||
metavar="PACKAGE",
|
||||
help="Workspace package selector or glob pattern, such as `core`.",
|
||||
)
|
||||
# Keep a hidden compatibility alias while old automation and local
|
||||
# muscle memory migrate from ``--project`` to ``--package``.
|
||||
command.add_argument("--project", dest="project", help=argparse.SUPPRESS)
|
||||
|
||||
def add_syntax_mode_options(command: argparse.ArgumentParser) -> None:
|
||||
command.add_argument("-F", "--format", action="store_true", help="Run formatting only.")
|
||||
command.add_argument("-C", "--check", action="store_true", help="Run lint checks only.")
|
||||
|
||||
def add_all_option(command: argparse.ArgumentParser) -> None:
|
||||
command.add_argument("-A", "--all", action="store_true", help="Run a single aggregate workspace sweep.")
|
||||
|
||||
def add_samples_option(command: argparse.ArgumentParser) -> None:
|
||||
command.add_argument("-S", "--samples", action="store_true", help="Target samples/ instead of packages.")
|
||||
|
||||
def add_cov_option(command: argparse.ArgumentParser) -> None:
|
||||
command.add_argument("-C", "--cov", action="store_true", help="Enable coverage output.")
|
||||
|
||||
syntax = subparsers.add_parser("syntax")
|
||||
add_project_option(syntax)
|
||||
add_samples_option(syntax)
|
||||
add_syntax_mode_options(syntax)
|
||||
|
||||
for command_name in ("fmt", "build", "clean-dist", "check-packages"):
|
||||
command = subparsers.add_parser(command_name)
|
||||
add_project_option(command)
|
||||
|
||||
lint = subparsers.add_parser("lint")
|
||||
add_project_option(lint)
|
||||
add_samples_option(lint)
|
||||
|
||||
pyright = subparsers.add_parser("pyright")
|
||||
add_project_option(pyright)
|
||||
add_all_option(pyright)
|
||||
add_samples_option(pyright)
|
||||
|
||||
mypy = subparsers.add_parser("mypy")
|
||||
add_project_option(mypy)
|
||||
add_all_option(mypy)
|
||||
|
||||
test_typing = subparsers.add_parser("test-typing")
|
||||
add_project_option(test_typing)
|
||||
add_all_option(test_typing)
|
||||
add_samples_option(test_typing)
|
||||
|
||||
typing = subparsers.add_parser("typing")
|
||||
add_project_option(typing)
|
||||
add_all_option(typing)
|
||||
|
||||
test = subparsers.add_parser("test")
|
||||
add_project_option(test)
|
||||
add_all_option(test)
|
||||
add_cov_option(test)
|
||||
|
||||
check = subparsers.add_parser("check")
|
||||
add_project_option(check)
|
||||
add_samples_option(check)
|
||||
|
||||
prek_check = subparsers.add_parser("prek-check")
|
||||
prek_check.add_argument("files", nargs="*", default=["."], help="Files reported by pre-commit.")
|
||||
|
||||
subparsers.add_parser("ci-mypy")
|
||||
subparsers.add_parser("ci-test-typing")
|
||||
|
||||
return parser.parse_known_args(argv)
|
||||
|
||||
|
||||
def load_toml(file_path: Path) -> dict:
|
||||
"""Load a TOML file."""
|
||||
with file_path.open("rb") as file:
|
||||
return tomli.load(file)
|
||||
|
||||
|
||||
def discover_workspace_projects() -> list[WorkspaceProject]:
|
||||
"""Return workspace packages together with their Python-version metadata."""
|
||||
projects: list[WorkspaceProject] = []
|
||||
for project_path in discover_projects(WORKSPACE_PYPROJECT):
|
||||
pyproject = load_toml(WORKSPACE_ROOT / project_path / "pyproject.toml")
|
||||
requires_python = pyproject.get("project", {}).get("requires-python")
|
||||
distribution_name = str(pyproject.get("project", {}).get("name", "")).strip()
|
||||
projects.append(
|
||||
WorkspaceProject(
|
||||
path=project_path,
|
||||
name=project_path.name,
|
||||
distribution_name=distribution_name,
|
||||
requires_python=requires_python,
|
||||
)
|
||||
)
|
||||
return projects
|
||||
|
||||
|
||||
def supports_current_python(project: WorkspaceProject) -> bool:
|
||||
"""Return whether the current interpreter satisfies the project's Python requirement."""
|
||||
if not project.requires_python:
|
||||
return True
|
||||
return SpecifierSet(project.requires_python).contains(CURRENT_PYTHON, prereleases=True)
|
||||
|
||||
|
||||
def select_projects(pattern: str) -> list[WorkspaceProject]:
|
||||
"""Select supported workspace projects that match the supplied pattern.
|
||||
|
||||
The shared matcher accepts short names such as ``core``, legacy path-style
|
||||
values, and distribution names so every root task family speaks the same
|
||||
selector dialect.
|
||||
"""
|
||||
matched_projects = [
|
||||
project
|
||||
for project in discover_workspace_projects()
|
||||
if project_filter_matches(project.path, pattern, aliases=[project.name, project.distribution_name])
|
||||
]
|
||||
if not matched_projects:
|
||||
print(f"[red]No workspace projects matched pattern '{pattern}'.[/red]")
|
||||
raise SystemExit(2)
|
||||
|
||||
supported_projects = [project for project in matched_projects if supports_current_python(project)]
|
||||
unsupported_projects = [project.name for project in matched_projects if not supports_current_python(project)]
|
||||
if unsupported_projects:
|
||||
version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
print(
|
||||
"[yellow]Skipping packages not supported by "
|
||||
f"Python {version}: {', '.join(sorted(unsupported_projects))}[/yellow]"
|
||||
)
|
||||
|
||||
return supported_projects
|
||||
|
||||
|
||||
def relative_path(path: Path) -> str:
|
||||
"""Convert a workspace path to a stable relative string."""
|
||||
return path.relative_to(WORKSPACE_ROOT).as_posix()
|
||||
|
||||
|
||||
def collect_source_dirs(projects: list[WorkspaceProject]) -> list[Path]:
|
||||
"""Collect top-level import package directories for the selected projects."""
|
||||
source_dirs: set[Path] = set()
|
||||
for project in projects:
|
||||
project_root = WORKSPACE_ROOT / project.path
|
||||
for init_file in project_root.rglob("__init__.py"):
|
||||
package_dir = init_file.parent
|
||||
if package_dir.name.startswith("agent_framework"):
|
||||
source_dirs.add(package_dir)
|
||||
return sorted(source_dirs)
|
||||
|
||||
|
||||
def collect_test_dirs(projects: list[WorkspaceProject]) -> list[Path]:
|
||||
"""Collect test directories for the selected projects."""
|
||||
test_dirs: set[Path] = set()
|
||||
for project in projects:
|
||||
project_root = WORKSPACE_ROOT / project.path
|
||||
for directory_name in ("tests", "ag_ui_tests"):
|
||||
for test_dir in project_root.rglob(directory_name):
|
||||
relative_test_dir = test_dir.relative_to(project_root)
|
||||
# Ignore hidden/generated trees such as ``.mypy_cache`` so the
|
||||
# aggregate sweep only targets real repository test directories.
|
||||
if test_dir.is_dir() and not any(part.startswith(".") for part in relative_test_dir.parts):
|
||||
test_dirs.add(test_dir)
|
||||
return sorted(test_dirs)
|
||||
|
||||
|
||||
def run_command(command: list[str]) -> None:
|
||||
"""Run a subprocess from the workspace root and stream its output."""
|
||||
result = subprocess.run(command, cwd=WORKSPACE_ROOT, check=False)
|
||||
if result.returncode:
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
|
||||
def run_fan_out(task_names: list[str], project_pattern: str, task_args: list[str]) -> None:
|
||||
"""Run package-local Poe tasks across the selected projects."""
|
||||
selected_projects = select_projects(project_pattern)
|
||||
if not selected_projects:
|
||||
print("[yellow]No selected projects support the current Python version, skipping.[/yellow]")
|
||||
return
|
||||
|
||||
work_items = build_work_items([project.path for project in selected_projects], task_names)
|
||||
run_tasks(work_items, WORKSPACE_ROOT, task_args=task_args)
|
||||
|
||||
|
||||
def sample_pyright_config() -> str:
|
||||
"""Return the sample Pyright configuration for the current interpreter."""
|
||||
if sys.version_info < (3, 11):
|
||||
return "pyrightconfig.samples.py310.json"
|
||||
return "pyrightconfig.samples.json"
|
||||
|
||||
|
||||
def run_sample_lint(extra_args: list[str]) -> None:
|
||||
"""Run linting against samples/."""
|
||||
command = [
|
||||
"uv",
|
||||
"run",
|
||||
"ruff",
|
||||
"check",
|
||||
"samples",
|
||||
"--fix",
|
||||
"--exclude",
|
||||
SAMPLE_EXCLUDES,
|
||||
"--ignore",
|
||||
SAMPLE_RUFF_IGNORE,
|
||||
*extra_args,
|
||||
]
|
||||
run_command(command)
|
||||
|
||||
|
||||
def run_sample_format(extra_args: list[str]) -> None:
|
||||
"""Run formatting against samples/."""
|
||||
command = [
|
||||
"uv",
|
||||
"run",
|
||||
"ruff",
|
||||
"format",
|
||||
"samples",
|
||||
"--exclude",
|
||||
SAMPLE_EXCLUDES,
|
||||
*extra_args,
|
||||
]
|
||||
run_command(command)
|
||||
|
||||
|
||||
def run_sample_pyright(extra_args: list[str]) -> None:
|
||||
"""Run sample syntax/import validation."""
|
||||
command = ["uv", "run", "pyright", "-p", sample_pyright_config(), "--warnings", *extra_args]
|
||||
run_command(command)
|
||||
|
||||
|
||||
def run_markdown_code_lint(files: list[str] | None = None) -> None:
|
||||
"""Run markdown code-block linting globally or for the changed markdown files only."""
|
||||
command = [
|
||||
"uv",
|
||||
"run",
|
||||
"python",
|
||||
"scripts/check_md_code_blocks.py",
|
||||
]
|
||||
if files is None:
|
||||
command.extend([
|
||||
"README.md",
|
||||
"./packages/**/README.md",
|
||||
"./samples/**/*.md",
|
||||
])
|
||||
else:
|
||||
if not files:
|
||||
print("[yellow]No markdown files changed, skipping markdown code lint.[/yellow]")
|
||||
return
|
||||
command.extend(files)
|
||||
command.append("--no-glob")
|
||||
|
||||
for excluded_path in MARKDOWN_EXCLUDES:
|
||||
command.extend(["--exclude", excluded_path])
|
||||
run_command(command)
|
||||
|
||||
|
||||
def run_aggregate_pyright(project_pattern: str, extra_args: list[str]) -> None:
|
||||
"""Run a single Pyright sweep across the selected project roots."""
|
||||
projects = select_projects(project_pattern)
|
||||
if not projects:
|
||||
print("[yellow]No selected projects support the current Python version, skipping.[/yellow]")
|
||||
return
|
||||
|
||||
project_paths = [relative_path(WORKSPACE_ROOT / project.path) for project in projects]
|
||||
run_command(["uv", "run", "pyright", *extra_args, *project_paths])
|
||||
|
||||
|
||||
# Type checkers that run over tests (and, where supported, samples). Pyright is the strict
|
||||
# SOURCE-code checker, and ALSO runs over tests + samples in a relaxed ``basic`` profile
|
||||
# (see pyrightconfig.tests.json / pyrightconfig.samples.json). mypy/pyrefly/ty/zuban
|
||||
# exercise the public API the way users do. All five run by default and gate CI. ``zuban``
|
||||
# is the strictest of the mypy-compatible pair and only runs on tests (samples are
|
||||
# script-style and unsupported by mypy/zuban -- see SAMPLE_TYPING_CHECKERS).
|
||||
GATING_TEST_TYPING_CHECKERS = ("mypy", "pyrefly", "ty", "zuban", "pyright")
|
||||
|
||||
|
||||
def _gating_checker_args() -> list[str]:
|
||||
"""Build ``--checker`` selectors for the CI-gating test/sample checkers."""
|
||||
args: list[str] = []
|
||||
for checker in GATING_TEST_TYPING_CHECKERS:
|
||||
args.extend(["--checker", checker])
|
||||
return args
|
||||
|
||||
|
||||
# Samples that are intentionally excluded from type checking (migrations, generated, etc.).
|
||||
SAMPLE_TYPING_EXCLUDES = (
|
||||
"autogen-migration",
|
||||
"semantic-kernel-migration",
|
||||
"autogen",
|
||||
"demos",
|
||||
"_to_delete",
|
||||
"05-end-to-end",
|
||||
"harness",
|
||||
)
|
||||
|
||||
|
||||
def _mypy_command(paths: list[str], *, samples: bool) -> list[str]:
|
||||
# The test-typing fan-out runs many mypy processes concurrently. mypy defaults to a
|
||||
# single shared ``.mypy_cache`` in the working directory; concurrent writes corrupt it
|
||||
# and mypy aborts with ``INTERNAL ERROR``. Give each invocation an isolated cache dir
|
||||
# keyed by its target paths so incremental caching still works per package without races.
|
||||
cache_key = hashlib.sha256("\0".join(sorted(paths)).encode()).hexdigest()[:16]
|
||||
cache_dir = Path(".mypy_cache") / ("samples" if samples else "tests") / cache_key
|
||||
command = [
|
||||
"uv",
|
||||
"run",
|
||||
"mypy",
|
||||
"--config-file",
|
||||
"pyproject.toml",
|
||||
"--cache-dir",
|
||||
str(cache_dir),
|
||||
"--explicit-package-bases",
|
||||
"--namespace-packages",
|
||||
]
|
||||
if samples:
|
||||
for excluded in SAMPLE_TYPING_EXCLUDES:
|
||||
command.extend(["--exclude", excluded])
|
||||
command.extend(paths)
|
||||
return command
|
||||
|
||||
|
||||
def _zuban_command(paths: list[str], *, samples: bool) -> list[str]:
|
||||
command = ["uv", "run", "zuban", "mypy", "--config-file", "pyproject.toml"]
|
||||
if samples:
|
||||
for excluded in SAMPLE_TYPING_EXCLUDES:
|
||||
command.extend(["--exclude", excluded])
|
||||
command.extend(paths)
|
||||
return command
|
||||
|
||||
|
||||
def _pyrefly_command(paths: list[str], *, samples: bool) -> list[str]:
|
||||
config = "pyrefly.samples.toml" if samples else "pyrefly.toml"
|
||||
command = ["uv", "run", "pyrefly", "check", "-c", config]
|
||||
if samples:
|
||||
for excluded in SAMPLE_TYPING_EXCLUDES:
|
||||
command.extend(["--project-excludes", f"**/{excluded}/**"])
|
||||
command.extend(paths)
|
||||
return command
|
||||
|
||||
|
||||
def _ty_command(paths: list[str], *, samples: bool) -> list[str]:
|
||||
command = ["uv", "run", "ty", "check"]
|
||||
if samples:
|
||||
command.extend(["--config-file", "ty.samples.toml"])
|
||||
for excluded in SAMPLE_TYPING_EXCLUDES:
|
||||
command.extend(["--exclude", f"**/{excluded}/**"])
|
||||
command.extend(paths)
|
||||
return command
|
||||
|
||||
|
||||
def _pyright_command(paths: list[str], *, samples: bool) -> list[str]:
|
||||
# Pyright owns source in strict mode; over tests + samples it runs in a relaxed
|
||||
# ``basic`` profile via a dedicated config (see pyrightconfig.tests.json /
|
||||
# pyrightconfig.samples.json). CLI paths override the config ``include``; the sample
|
||||
# excludes live in the config itself (Pyright has no ``--exclude`` CLI flag).
|
||||
config = sample_pyright_config() if samples else "pyrightconfig.tests.json"
|
||||
return ["uv", "run", "pyright", "-p", config, *paths]
|
||||
|
||||
|
||||
CHECKER_COMMANDS = {
|
||||
"mypy": _mypy_command,
|
||||
"zuban": _zuban_command,
|
||||
"pyrefly": _pyrefly_command,
|
||||
"ty": _ty_command,
|
||||
"pyright": _pyright_command,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_checkers(extra_args: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Split a ``--checker NAME`` selector (repeatable) from pass-through args.
|
||||
|
||||
With no explicit ``--checker``, all gating checkers (mypy, pyrefly, ty, zuban,
|
||||
pyright) run. A single checker can be isolated via e.g. ``--checker pyright``.
|
||||
"""
|
||||
checkers: list[str] = []
|
||||
passthrough: list[str] = []
|
||||
index = 0
|
||||
while index < len(extra_args):
|
||||
argument = extra_args[index]
|
||||
if argument == "--checker" and index + 1 < len(extra_args):
|
||||
checkers.append(extra_args[index + 1])
|
||||
index += 2
|
||||
continue
|
||||
passthrough.append(argument)
|
||||
index += 1
|
||||
selected = checkers or list(GATING_TEST_TYPING_CHECKERS)
|
||||
unknown = [name for name in selected if name not in CHECKER_COMMANDS]
|
||||
if unknown:
|
||||
print(f"[red]Unknown checker(s): {', '.join(unknown)}.[/red]")
|
||||
raise SystemExit(2)
|
||||
return selected, passthrough
|
||||
|
||||
|
||||
def run_test_typing(project_pattern: str, extra_args: list[str]) -> None:
|
||||
"""Run the test-suite type checkers (mypy, pyrefly, ty, zuban, pyright) per package.
|
||||
|
||||
Each (package, checker) pair is fanned out in parallel via the same executor
|
||||
used by the pyright source fan-out, so the presentation and parallelism match.
|
||||
"""
|
||||
checkers, passthrough = _resolve_checkers(extra_args)
|
||||
projects = select_projects(project_pattern)
|
||||
if not projects:
|
||||
print("[yellow]No selected projects support the current Python version, skipping.[/yellow]")
|
||||
return
|
||||
|
||||
command_items: list[tuple[str, list[str]]] = []
|
||||
for project in projects:
|
||||
test_dirs = collect_test_dirs([project])
|
||||
if not test_dirs:
|
||||
continue
|
||||
paths = [relative_path(path) for path in test_dirs]
|
||||
for checker in checkers:
|
||||
command = CHECKER_COMMANDS[checker]([*paths, *passthrough], samples=False)
|
||||
command_items.append((f"{checker} :: {project.name}", command))
|
||||
|
||||
run_command_items(command_items, WORKSPACE_ROOT)
|
||||
|
||||
|
||||
# Checkers that work on the script-style samples tree. MyPy/zuban are excluded because
|
||||
# samples are standalone scripts (numeric-prefixed dirs, duplicate filenames like main.py)
|
||||
# that cannot be resolved into a module package tree without per-file invocation. Pyright
|
||||
# handles the script-style tree fine and runs in the relaxed ``basic`` samples profile.
|
||||
SAMPLE_TYPING_CHECKERS = ("pyrefly", "ty", "pyright")
|
||||
|
||||
|
||||
def run_sample_typing(extra_args: list[str]) -> None:
|
||||
"""Run the sample-capable type checkers over samples/ in the relaxed/basic profile."""
|
||||
checkers, passthrough = _resolve_checkers(extra_args)
|
||||
sample_checkers = [checker for checker in checkers if checker in SAMPLE_TYPING_CHECKERS]
|
||||
skipped = [checker for checker in checkers if checker not in SAMPLE_TYPING_CHECKERS]
|
||||
if skipped:
|
||||
print(f"[yellow]Skipping {', '.join(skipped)} for samples (script-style tree is unsupported).[/yellow]")
|
||||
if not sample_checkers:
|
||||
return
|
||||
command_items = [
|
||||
(f"{checker} :: samples", CHECKER_COMMANDS[checker](["samples", *passthrough], samples=True))
|
||||
for checker in sample_checkers
|
||||
]
|
||||
run_command_items(command_items, WORKSPACE_ROOT)
|
||||
|
||||
|
||||
def run_aggregate_test(project_pattern: str, cov: bool, extra_args: list[str]) -> None:
|
||||
"""Run a single pytest sweep across the selected project test directories."""
|
||||
projects = select_projects(project_pattern)
|
||||
if not projects:
|
||||
print("[yellow]No selected projects support the current Python version, skipping.[/yellow]")
|
||||
return
|
||||
|
||||
if project_pattern == "*":
|
||||
# Preserve the legacy ``all-tests`` contract when ``test --all`` runs with
|
||||
# the default selector: experimental packages stay opt-in instead of
|
||||
# suddenly joining every PR unit-test sweep.
|
||||
projects = [project for project in projects if project.name not in DEFAULT_AGGREGATE_TEST_EXCLUDES]
|
||||
if not projects:
|
||||
print("[yellow]No aggregate-test projects remain after applying default exclusions.[/yellow]")
|
||||
return
|
||||
|
||||
test_dirs = [relative_path(path) for path in collect_test_dirs(projects)]
|
||||
if not test_dirs:
|
||||
print("[yellow]No test directories found for the selected projects, skipping pytest.[/yellow]")
|
||||
return
|
||||
|
||||
command = [
|
||||
"uv",
|
||||
"run",
|
||||
"pytest",
|
||||
"--import-mode=importlib",
|
||||
"-m",
|
||||
"not integration",
|
||||
"-rs",
|
||||
"-n",
|
||||
"logical",
|
||||
"--dist",
|
||||
"worksteal",
|
||||
]
|
||||
if cov:
|
||||
for source_dir in collect_source_dirs(projects):
|
||||
command.append(f"--cov={source_dir.name}")
|
||||
command.extend(["--cov-config=pyproject.toml", "--cov-report=term-missing:skip-covered"])
|
||||
|
||||
command.extend(extra_args)
|
||||
command.extend(test_dirs)
|
||||
run_command(command)
|
||||
|
||||
|
||||
def normalize_changed_file(file_path: str) -> str:
|
||||
"""Normalize changed-file paths passed from git or pre-commit."""
|
||||
normalized = file_path.replace("\\", "/")
|
||||
if normalized.startswith("python/"):
|
||||
return normalized[7:]
|
||||
return normalized
|
||||
|
||||
|
||||
def has_changed_sample_files(files: list[str]) -> bool:
|
||||
"""Return whether any changed file lives under samples/."""
|
||||
return any(normalize_changed_file(file_path).startswith("samples/") for file_path in files)
|
||||
|
||||
|
||||
def changed_markdown_files(files: list[str]) -> list[str]:
|
||||
"""Return markdown files from the provided change list."""
|
||||
markdown_files = [normalize_changed_file(file_path) for file_path in files]
|
||||
return sorted({file_path for file_path in markdown_files if file_path.endswith(".md")})
|
||||
|
||||
|
||||
def run_changed_package_tasks(task_names: list[str], files: list[str]) -> None:
|
||||
"""Run package-local tasks only in packages affected by the provided file list."""
|
||||
command = [
|
||||
"uv",
|
||||
"run",
|
||||
"python",
|
||||
"scripts/run_tasks_in_changed_packages.py",
|
||||
*task_names,
|
||||
"--files",
|
||||
*files,
|
||||
]
|
||||
run_command(command)
|
||||
|
||||
|
||||
def run_prek_check(files: list[str]) -> None:
|
||||
"""Run the lightweight pre-commit task surface."""
|
||||
normalized_files = [normalize_changed_file(file_path) for file_path in files] or ["."]
|
||||
run_changed_package_tasks(["fmt", "lint"], normalized_files)
|
||||
run_markdown_code_lint(changed_markdown_files(normalized_files))
|
||||
if has_changed_sample_files(normalized_files):
|
||||
print("[cyan]Sample files changed, running sample checks.[/cyan]")
|
||||
run_sample_lint([])
|
||||
run_sample_pyright([])
|
||||
else:
|
||||
print("[yellow]No sample files changed, skipping sample checks.[/yellow]")
|
||||
|
||||
|
||||
def run_ci_test_typing() -> None:
|
||||
"""Run the gating test/sample type checkers across the workspace, mirroring CI."""
|
||||
run_test_typing("*", _gating_checker_args())
|
||||
run_sample_typing(_gating_checker_args())
|
||||
|
||||
|
||||
def ensure_no_extra_args(command_name: str, extra_args: list[str]) -> None:
|
||||
"""Reject unsupported pass-through arguments for commands that do not forward them."""
|
||||
if extra_args:
|
||||
joined_args = " ".join(extra_args)
|
||||
print(f"[red]Command '{command_name}' does not accept extra arguments: {joined_args}[/red]")
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def resolve_syntax_modes(*, format_selected: bool, check_selected: bool) -> tuple[bool, bool]:
|
||||
"""Resolve which syntax steps to run."""
|
||||
if not format_selected and not check_selected:
|
||||
return True, True
|
||||
return format_selected, check_selected
|
||||
|
||||
|
||||
def run_syntax(
|
||||
*,
|
||||
project_pattern: str,
|
||||
samples: bool,
|
||||
format_selected: bool,
|
||||
check_selected: bool,
|
||||
extra_args: list[str],
|
||||
) -> None:
|
||||
"""Run formatting and/or lint checking for packages or samples.
|
||||
|
||||
Combined package mode deliberately dispatches ``fmt`` and ``lint`` together
|
||||
so the shared task runner can start both legs in parallel.
|
||||
"""
|
||||
run_format, run_check = resolve_syntax_modes(
|
||||
format_selected=format_selected,
|
||||
check_selected=check_selected,
|
||||
)
|
||||
if run_format and run_check and extra_args:
|
||||
joined_args = " ".join(extra_args)
|
||||
print(
|
||||
"[red]Extra arguments are only supported when syntax runs a single mode; "
|
||||
f"use either --format or --check with: {joined_args}[/red]"
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
if samples and project_pattern != "*":
|
||||
print("[red]--samples cannot be combined with --package.[/red]")
|
||||
raise SystemExit(2)
|
||||
|
||||
format_args = extra_args if run_format and not run_check else []
|
||||
check_args = extra_args if run_check and not run_format else []
|
||||
|
||||
if samples:
|
||||
if run_format:
|
||||
run_sample_format(format_args)
|
||||
if run_check:
|
||||
run_sample_lint(check_args)
|
||||
return
|
||||
|
||||
if run_format and run_check:
|
||||
# Fan out both legs in one call so task_runner can parallelize format
|
||||
# and lint work across the same selected package set.
|
||||
run_fan_out(["fmt", "lint"], project_pattern, [])
|
||||
return
|
||||
|
||||
if run_format:
|
||||
run_fan_out(["fmt"], project_pattern, format_args)
|
||||
if run_check:
|
||||
run_fan_out(["lint"], project_pattern, check_args)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Dispatch the requested workspace task."""
|
||||
args, extra_args = parse_args(sys.argv[1:])
|
||||
|
||||
if args.command == "syntax":
|
||||
run_syntax(
|
||||
project_pattern=args.project,
|
||||
samples=args.samples,
|
||||
format_selected=args.format,
|
||||
check_selected=args.check,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
return
|
||||
|
||||
if args.command == "fmt":
|
||||
run_syntax(
|
||||
project_pattern=args.project,
|
||||
samples=False,
|
||||
format_selected=True,
|
||||
check_selected=False,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
return
|
||||
|
||||
if args.command == "lint":
|
||||
if args.samples:
|
||||
run_syntax(
|
||||
project_pattern=args.project,
|
||||
samples=True,
|
||||
format_selected=False,
|
||||
check_selected=True,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
return
|
||||
run_syntax(
|
||||
project_pattern=args.project,
|
||||
samples=False,
|
||||
format_selected=False,
|
||||
check_selected=True,
|
||||
extra_args=extra_args,
|
||||
)
|
||||
return
|
||||
|
||||
if args.command == "pyright":
|
||||
if args.samples:
|
||||
if args.all or args.project != "*":
|
||||
print("[red]--samples cannot be combined with --all or --package.[/red]")
|
||||
raise SystemExit(2)
|
||||
run_sample_pyright(extra_args)
|
||||
return
|
||||
if args.all:
|
||||
run_aggregate_pyright(args.project, extra_args)
|
||||
return
|
||||
run_fan_out(["pyright"], args.project, extra_args)
|
||||
return
|
||||
|
||||
if args.command == "mypy":
|
||||
# MyPy no longer runs on source code (Pyright owns source). The ``mypy`` task is a
|
||||
# convenience alias that runs MyPy over the test suite.
|
||||
run_test_typing(args.project, ["--checker", "mypy", *extra_args])
|
||||
return
|
||||
|
||||
if args.command == "test-typing":
|
||||
if args.samples:
|
||||
if args.all or args.project != "*":
|
||||
print("[red]--samples cannot be combined with --all or --package.[/red]")
|
||||
raise SystemExit(2)
|
||||
run_sample_typing(extra_args)
|
||||
return
|
||||
run_test_typing(args.project, extra_args)
|
||||
return
|
||||
|
||||
if args.command == "typing":
|
||||
ensure_no_extra_args(args.command, extra_args)
|
||||
# Pyright over source, then the multi-checker sweep over the tests.
|
||||
if args.all:
|
||||
run_aggregate_pyright(args.project, [])
|
||||
else:
|
||||
run_fan_out(["pyright"], args.project, [])
|
||||
run_test_typing(args.project, [])
|
||||
return
|
||||
|
||||
if args.command == "test":
|
||||
if args.all:
|
||||
run_aggregate_test(args.project, args.cov, extra_args)
|
||||
return
|
||||
run_fan_out(["test"], args.project, extra_args)
|
||||
return
|
||||
|
||||
if args.command == "build":
|
||||
ensure_no_extra_args(args.command, extra_args)
|
||||
run_fan_out(["build"], args.project, [])
|
||||
return
|
||||
|
||||
if args.command == "clean-dist":
|
||||
ensure_no_extra_args(args.command, extra_args)
|
||||
run_fan_out(["clean-dist"], args.project, [])
|
||||
return
|
||||
|
||||
if args.command == "check-packages":
|
||||
ensure_no_extra_args(args.command, extra_args)
|
||||
run_syntax(
|
||||
project_pattern=args.project,
|
||||
samples=False,
|
||||
format_selected=False,
|
||||
check_selected=False,
|
||||
extra_args=[],
|
||||
)
|
||||
run_fan_out(["pyright"], args.project, [])
|
||||
return
|
||||
|
||||
if args.command == "check":
|
||||
ensure_no_extra_args(args.command, extra_args)
|
||||
if args.samples:
|
||||
if args.project != "*":
|
||||
print("[red]--samples cannot be combined with --package.[/red]")
|
||||
raise SystemExit(2)
|
||||
run_syntax(
|
||||
project_pattern="*",
|
||||
samples=True,
|
||||
format_selected=False,
|
||||
check_selected=False,
|
||||
extra_args=[],
|
||||
)
|
||||
run_sample_typing(_gating_checker_args())
|
||||
return
|
||||
run_syntax(
|
||||
project_pattern=args.project,
|
||||
samples=False,
|
||||
format_selected=False,
|
||||
check_selected=False,
|
||||
extra_args=[],
|
||||
)
|
||||
run_fan_out(["pyright"], args.project, [])
|
||||
run_test_typing(args.project, _gating_checker_args())
|
||||
run_fan_out(["test"], args.project, [])
|
||||
# Sample validation and markdown lint are intentionally workspace-wide;
|
||||
# a package-scoped check should stay focused on the selected package set.
|
||||
if args.project == "*":
|
||||
run_syntax(
|
||||
project_pattern="*",
|
||||
samples=True,
|
||||
format_selected=False,
|
||||
check_selected=False,
|
||||
extra_args=[],
|
||||
)
|
||||
run_sample_typing(_gating_checker_args())
|
||||
run_markdown_code_lint()
|
||||
return
|
||||
|
||||
if args.command == "prek-check":
|
||||
ensure_no_extra_args(args.command, extra_args)
|
||||
run_prek_check(args.files)
|
||||
return
|
||||
|
||||
if args.command in ("ci-mypy", "ci-test-typing"):
|
||||
ensure_no_extra_args(args.command, extra_args)
|
||||
run_ci_test_typing()
|
||||
return
|
||||
|
||||
print(f"[red]Unsupported command: {args.command}[/red]")
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user