db620d33df
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
829 lines
29 KiB
Python
829 lines
29 KiB
Python
# 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()
|