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:
Eduard van Valkenburg
2026-03-13 13:32:37 +01:00
committed by GitHub
Unverified
parent 67b0282813
commit 50fdcbaf57
61 changed files with 5500 additions and 779 deletions
+5
View File
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
"""Shared Python workspace scripts."""
+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 --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()