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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
"""Shared Python workspace scripts."""
+157
View File
@@ -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)
+95
View File
@@ -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()
+182
View File
@@ -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))
+161
View File
@@ -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
],
}
+122
View File
@@ -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"]
+300
View File
@@ -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]")
+828
View File
@@ -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()