6e95517659
* Python: Split type checkers by target (pyright source, 5 checkers on tests/samples) Rework the typing setup along the lines of the 'too many type checkers' approach: - Pyright (strict) is now the sole source-code type checker; mypy is removed from source and its [tool.mypy] block becomes a relaxed profile used only for tests/samples. - Tests are checked by all five checkers (pyright relaxed, mypy, pyrefly, ty, zuban); samples by pyright, pyrefly, and ty. All run in a relaxed/ basic profile so authors aren't forced into over-annotation. - Add pyrightconfig.tests.json and bump sample pyright configs to basic. - Unify test/sample typing onto the same parallel fan-out used by source pyright via run_command_items in task_runner.py. - Make version-conditional imports symmetric: keep or drop the '# type: ignore' on both branches so results match across interpreter versions (local vs CI). - Update SKILL.md, DEV_SETUP.md, and CODING_STANDARD.md for the five gating checkers and pyright on source+tests+samples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix merge regressions from main (typing + runtime) Merging main into the type-checker split branch surfaced regressions that the new five-checker test suite and unit tests caught: Runtime fixes: - anthropic: restore the dropped `cache_read_input_token_count` mapping in _parse_usage_from_anthropic (lost during merge conflict resolution). - gemini: _get_function_calling_mode test helper returned str(enum) ('FunctionCallingConfigMode.AUTO') instead of the enum value ('AUTO'). - openai: _response_id_from_token test helper was an infinite self-recursion; return token['response_id']. - orchestrations: reset output_events per approval iteration so the terminal output assertion counts only the final run. - core: drop a stale duplicate harness test whose message ('non-negative') contradicted the source ('positive'). - purview: import PolicyLocation/PolicyScope/ProtectionScopeActivities/ ExecutionMode used by the processor tests. Type-checker fixes (tests, relaxed profile): - core: pyright/mypy/pyrefly/ty/zuban green-ups across the harness, MCP, observability and types tests. - anthropic/openai: route provider-namespaced UsageDetails keys through a dict cast (extra_items TypedDict unsupported by mypy/ty). - purview: typed model constructors and cache-mock casts. - ag-ui: annotate WorkflowContext[Any, Any] so yield_output accepts test payloads, guard Optional forwarded_props, and ty-ignore intentional bad args. Source pyright (sole source checker) flagged unnecessary ignores newly introduced by merged code in core _tools.py and declarative _declarative_base.py. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Isolate per-package mypy cache in test-typing fan-out The parallel test-typing fan-out runs many mypy processes concurrently, all defaulting to a single shared ./.mypy_cache. Concurrent writes corrupt the cache and mypy aborts with INTERNAL ERROR (intermittently, depending on worker timing) -- which is why CI's Test Typing job failed on a shifting set of packages while a single-package run was fine. Give each mypy invocation an isolated cache dir keyed by its target paths so incremental caching still works per package without races. Other checkers (zuban/pyrefly/ty/pyright) maintain their own caches and are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Make lab pyright-only on source (drop source mypy) Lab was the last package still running mypy on its source code, requiring mypy-only `# type: ignore` comments that pyright (the sole source checker everywhere else) flags as unnecessary. Align lab with the rest of the monorepo: - Remove the lab source mypy poe tasks (mypy-gaia/lightning/tau2) and the now-dead strict [tool.mypy] config block. - Drop the 'Run lab mypy' CI step; lab source is type-checked by pyright only. Lab tests remain covered by the workspace test-typing fan-out (mypy, pyrefly, ty, zuban, pyright over tests using the relaxed root config). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix test-typing regressions from latest main merge A fresh merge from main brought in new test code never run under the five-checker test-typing suite. Green up across the affected packages: - core: narrow Optional span.attributes with 'and' guards in span filters and assert+cast the json.loads(...attributes[...]) reads (test_observability); match the existing as_agent ignore on the protocol-typed fixture (test_clients). - openai: align new streaming tests with the established chat_options dict pattern (ChatOptions TypedDict isn't assignable to dict), route Optional .annotations[0] access through a small _first_annotation helper (mirrors the file's assert-not-None convention), and annotate a mapped ResponseStream. - foundry_hosting: annotate error: dict[str, Any] = body.get(...) or {} (zuban needs the annotation). - foundry: narrow ignores for the live AIProjectClient credential arg (pyrefly) and connections.get_default (zuban) SDK type gaps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updated pyright version * pyright fix * Python: Fix source typing for pyright 1.1.410 Pyright 1.1.410 tightened several checks. Apply the same source fixes as upstream PR #6275: - anthropic: import AsyncAnthropicBedrock from anthropic.lib.bedrock and AsyncAnthropicVertex from anthropic.lib.vertex (no longer re-exported from the anthropic top-level package -> reportPrivateImportUsage). - core _types.py: cast the transform-hook result to UpdateT (reportAssignmentType). - core _workflows/_events.py: annotate the @contextmanager helper as Generator[None] instead of Iterator[None] (reportDeprecated). - redis: build the combined filter expression with an explicit loop instead of reduce(and_, ...), which pyright could no longer fully type (drops the now unused functools.reduce / operator.and_ imports). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Accept plain-text body in Azure Functions workflow/run endpoint The workflow_orchestrator already accepts plain strings as well as JSON objects via context.get_input(), but the start_workflow_orchestration HTTP handler only accepted JSON and returned 400 for any non-JSON body. This made the functions integration tests that POST text/plain to /api/workflow/run (e.g. test_09_workflow_shared_state) fail consistently with 400 != 202. Fall back to the raw request body (decoded as UTF-8) when the body is not JSON, rejecting only a truly empty body. The JSON path is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
301 lines
11 KiB
Python
301 lines
11 KiB
Python
# 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]")
|