mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: chore(python): improve dependency range automation (#4343)
* chore(python): improve dependency range automation - tighten dependency bounds and coding standards guidance\n- add dependency range validation workflow, reporting, and issue automation\n- update related tests and dependency pins for compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updated text and pyarrow * new lock * fixed workflow * updated deps * fix tiktoken * chore(python): refine dependency validation workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(python): add high-level dependency validation comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WIP * added additional comments and excludes * added dev dependency handling and workflow and updates to package ranges * added readme and simplified commands * fix markers * chore(python): address dependency review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten dependency bounds, remove stale overrides, restore Python 3.10 support - Apply dependency bound policy across all packages: stable >=1.0 deps use >=floor,<next_major; pre-1.0/prerelease deps use validated hard-bounded ranges - Remove stale root tool.uv.override-dependencies (uvicorn, websockets, grpcio) - Lower github_copilot requires-python to >=3.10 with github-copilot-sdk gated behind python_version >= 3.11 marker; import raises ImportError on 3.10 - Skip github_copilot pyright/mypy/test tasks on Python <3.11 - Use version-conditional pyrightconfig for samples on Python 3.10 - Add compatibility fix in core responses client for older openai typed dicts - Normalize uv.lock prerelease mode and refresh dev dependencies - Update CODING_STANDARD.md, DEV_SETUP.md, and package management skill docs Closes #902 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * small tweaks * add note in workflow * fix workflows and several versions * fix duplicate --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
67b0282813
commit
50fdcbaf57
@@ -0,0 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""Shared Python workspace scripts."""
|
||||
@@ -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 --project <workspace-package-name>
|
||||
uv run poe validate-dependency-bounds-project --mode both --project <workspace-package-name> --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 `--project` 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 --project "*"`
|
||||
- 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 packages/core --dry-run
|
||||
python -m scripts.dependencies.validate_dependency_bounds --mode both --package packages/core --dependencies openai --dry-run
|
||||
python -m scripts.dependencies._dependency_bounds_lower_impl --packages packages/core --dependencies openai --dry-run
|
||||
python -m scripts.dependencies._dependency_bounds_upper_impl --packages 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,180 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# ruff: noqa: INP001
|
||||
|
||||
"""Refresh dev dependency pins across the Python workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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_dev_pin_replacements,
|
||||
_load_lock_versions,
|
||||
)
|
||||
from scripts.task_runner import discover_projects
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceProject:
|
||||
"""Workspace project metadata used for dev 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 dev extras/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 dev dependency pins in workspace pyproject files."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Refresh dev 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 dev pin.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print planned replacements without updating files.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
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 dev 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,
|
||||
)
|
||||
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 dev pins are
|
||||
# formatted consistently regardless of whether we update them directly here or while
|
||||
# widening runtime dependency bounds.
|
||||
replacements = _collect_dev_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)} dev dependency pin refresh(es)."
|
||||
)
|
||||
|
||||
if updated_projects == 0:
|
||||
print("[green]No dev dependency pin updates were needed.[/green]")
|
||||
return
|
||||
|
||||
action = "Would update" if args.dry_run else "Updated"
|
||||
print(
|
||||
f"[green]{action} {updated_requirements} dev dependency pin(s) "
|
||||
f"across {updated_projects} workspace project(s).[/green]"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,490 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# ruff: noqa: INP001, 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.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
_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
|
||||
include_dev_group: bool
|
||||
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]:
|
||||
workspace_pyproject = workspace_root / "pyproject.toml"
|
||||
package_map = _build_workspace_package_map(workspace_root)
|
||||
internal_graph = _build_internal_graph(workspace_root, package_map)
|
||||
normalized_filter = None if package_filter in {None, "", "*"} else package_filter
|
||||
|
||||
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)
|
||||
if normalized_filter and str(project_path) != normalized_filter and package_name != normalized_filter:
|
||||
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,
|
||||
include_dev_group="dev" in 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)
|
||||
if plan.include_dev_group:
|
||||
command.extend(["--group", "dev"])
|
||||
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 path/name filter for all modes. 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()
|
||||
Reference in New Issue
Block a user