diff --git a/python/.github/skills/agent-framework-py-release/SKILL.md b/python/.github/skills/agent-framework-py-release/SKILL.md index af78284901f..43ab1b73ad4 100644 --- a/python/.github/skills/agent-framework-py-release/SKILL.md +++ b/python/.github/skills/agent-framework-py-release/SKILL.md @@ -28,7 +28,7 @@ For release work, derive the live tier map at release time from `python/PACKAGE_ ## Inputs to confirm before bumping -1. **The changeset**: explicit commits/PRs the release covers, OR derive from `git log ${LAST_RELEASED_TAG}..origin/main -- python/`. +1. **The changeset**: explicit commits/PRs the release covers, OR derive from `git log ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/`. 2. **Per-package CHANGELOG entries**: which packages will get a line in the new release section. This list IS the bump list. 3. **Per-released-package semver bump**: for each released-tier package that has a CHANGELOG entry, decide PATCH / MINOR / MAJOR. 4. **Date stamp** (only if any alpha/beta is being bumped): default from the `python-package-management` @@ -55,12 +55,20 @@ If the user states target versions or a date explicitly, use exactly what they s git fetch origin main --tags --quiet git fetch upstream main --tags --quiet 2>/dev/null || true git status + +# Fork clones use upstream/main as the authoritative release base; direct clones use origin/main. +if git show-ref --verify --quiet refs/remotes/upstream/main; then + RELEASE_BASE=upstream/main +else + RELEASE_BASE=origin/main +fi +git log -1 --oneline "$RELEASE_BASE" ``` If the user already has a `bump-py-ver-release-*` branch checked out, use it. Otherwise: ```bash -git checkout -b bump-py-ver-release-YYMMDD origin/main +git checkout -b bump-py-ver-release-YYMMDD "$RELEASE_BASE" ``` ### 2. Build the live tier map @@ -84,20 +92,20 @@ echo "Compare base: $LAST_RELEASED_TAG" List commits and packages touched: ```bash -git log --oneline ${LAST_RELEASED_TAG}..origin/main -- python/ ':!python/CHANGELOG.md' +git log --oneline ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/ ':!python/CHANGELOG.md' # Per-commit package footprint -for sha in $(git log --format='%H' ${LAST_RELEASED_TAG}..origin/main -- python/); do +for sha in $(git log --format='%H' ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/); do echo "--- $(git show -s --format='%h %s' $sha) ---" git show --name-only --format='' $sha | grep '^python/packages/' | \ sed 's|^python/packages/||' | awk -F/ '{print $1}' | sort -u done ``` -If the release ultimately tags from `upstream/main` but `origin/main` is behind, also run: +When both remotes exist, record whether the fork is behind the authoritative base: ```bash -git log --oneline ${LAST_RELEASED_TAG}..upstream/main -- python/ ':!python/CHANGELOG.md' +git rev-list --left-right --count origin/main...upstream/main ``` If user provides an explicit commit/PR list, treat THAT as authoritative. @@ -108,14 +116,14 @@ Aggregate the per-commit footprint into a single union across the whole range. T ```bash # Union of all touched package directories across the range -git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main -- python/packages/ \ +git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/packages/ \ | grep '^python/packages/' \ | sed 's|^python/packages/||' \ | awk -F/ '{print $1}' \ | sort -u # Root-level files (drive a root agent-framework entry if substantive) -git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main \ +git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} \ -- python/pyproject.toml python/agent_framework_meta/ python/README.md \ 2>/dev/null | grep -v '^$' | sort -u ``` @@ -175,13 +183,13 @@ Before moving on, prove that every ship-affecting touched package has at least o ```bash # 1. Touched ship-affecting packages and root package files (from step 3a) -TOUCHED_PACKAGES=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main -- python/packages/ \ +TOUCHED_PACKAGES=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/packages/ \ | grep '^python/packages/' \ | sed 's|^python/packages/||' \ | awk -F/ '{print $1}' \ | sort -u) -ROOT_TOUCHED=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main \ +ROOT_TOUCHED=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} \ -- python/pyproject.toml python/agent_framework_meta/ python/README.md \ 2>/dev/null | grep -v '^$' | sort -u) @@ -279,7 +287,7 @@ Spot-check with `grep '^version' python/pyproject.toml python/packages/*/pyproje Only relevant when `core` itself bumped this cycle. Two policies, pick one explicitly with the user: - **Conservative (default)**: raise `agent-framework-core>=X.Y.Z` to the new core version on every non-core package that is ALSO bumping this cycle. Leaves packages-not-bumped at their existing floor. -- **Strict per-upstream-doc**: only raise the floor on packages that actually consume a new core API introduced in the bump. This requires per-package code inspection. Use only when the user is comfortable letting `validate-dependency-bounds-test` (lower-resolution pass) catch any mistakes. +- **Strict per-upstream-doc**: only raise the floor on packages that actually consume a new core API introduced in the bump. This requires per-package code inspection because release probes use the co-released local core and cannot prove compatibility with an older published core floor. When raising a core floor, replace only the `>=OLD` half of the bound you intend to change: @@ -294,12 +302,29 @@ If `core` did not bump this cycle, do not touch floors. ### 7. Validate ```bash -cd python && uv run poe validate-dependency-bounds-test +cd python && uv run poe validate-python-release --base-ref "$RELEASE_BASE" ``` -Must exit 0. This is the safety net for selective bumping: the lower-resolution pass catches floors set too low for code that depends on new APIs, and the upper pass catches caps that exclude installable versions. If it fails, the output names the offending bound — fix and re-run before committing. This step also regenerates `uv.lock` to match new bounds. +Use the same freshly fetched main ref that the release branch was based on (`upstream/main` above; use `origin/main` +when that is the authoritative release base). Must exit 0. This task first regenerates `uv.lock`, then discovers the +package `pyproject.toml` files changed from that base and runs their published runtime dependencies and +non-development extras through lock-independent `lowest-direct` and `highest` import probes. The probes run in +parallel, derive the minimum supported Python minor from each package's internal editable closure, and share a hard +300-second deadline. Use `--python` only when the release requires an explicit interpreter override. + +This is the release safety net for selective bumping: the lower probe catches unresolvable or unimportable external +floors, internal constraints that reject co-released package versions, and the upper probe catches caps that exclude +an installable package set. The JSON report records the concrete versions resolved in both scenarios. It does not +replace the package-by-package code inspection required by the strict core-floor policy. If it fails, fix the named +package/bound and re-run before committing. + +Do not substitute the workspace-wide `validate-dependency-bounds-test` command here. That command runs every +package's full tests and Pyright in separate isolated environments and is intentionally reserved for CI or an +explicit dependency-range audit. If the release itself changes an external dependency range, also run +`validate-dependency-bounds-project --mode both --package --dependency ` for that dependency. -If only prereleases changed (no `core` bump, no floor changes), this validation is still required — `uv.lock` regeneration alone justifies the run. +If only prereleases changed (no `core` bump, no floor changes), release validation is still required because the +lockfile and both ends of each changed package's published dependency metadata must remain installable. ### 8. Commit (expect hook retry) @@ -349,7 +374,7 @@ The push output includes a `Create a pull request for '' on GitHub by vi do not infer a local timezone from the user's current shell. - **`Co-Authored-By` trailer.** Never add it. Rewrite/amend if it slipped in. - **Stale inventory in this skill.** Always read `python/PACKAGE_STATUS.md` for the live tier map. Do not trust a hardcoded list. -- **Divergent origin vs upstream.** If the release tags from `upstream/main` but `origin/main` is behind, check both — warn if they differ and offer to sync. +- **Divergent origin vs upstream.** In fork clones, use freshly fetched `upstream/main` consistently for branch creation, changeset discovery, and release validation. A stale `origin/main` must never become the implicit compare base. - **`--pre` README cleanup on promotion.** When a package is promoted to `released` in this cycle, grep for `pip install agent-framework- --pre` in READMEs and drop the `--pre` flag. - **RC counter inflation.** Do not increment `1.0.0rcN` without a CHANGELOG entry for that package. The counter tracks iterations, not calendar. @@ -357,5 +382,6 @@ The push output includes a `Create a pull request for '' on GitHub by vi - Package lifecycle and versioning source of truth: `python/.github/skills/python-package-management/SKILL.md` - Lifecycle source of truth: `python/PACKAGE_STATUS.md` -- Validator: `python/scripts/dependencies/validate_dependency_bounds.py` (runs `lowest-direct` and `highest` resolution smoke tests; catches floors/caps that don't match the code) +- Release validator: `python/scripts/dependencies/validate_dependency_bounds.py --mode release` (changed-package, + lock-independent `lowest-direct` and `highest` import probes under a five-minute deadline) - Poe task definitions: `python/pyproject.toml` `[tool.poe.tasks]` diff --git a/python/.github/skills/python-package-management/SKILL.md b/python/.github/skills/python-package-management/SKILL.md index 6a0af8269c3..102230576d1 100644 --- a/python/.github/skills/python-package-management/SKILL.md +++ b/python/.github/skills/python-package-management/SKILL.md @@ -45,9 +45,13 @@ uv lock --upgrade-package && uv run poe install # Refresh exact development dependency-group pins, lockfile, and validation in one run uv run poe upgrade-dev-dependencies -# First, run workspace-wide lower/upper compatibility gates +# Release cuts: refresh uv.lock and probe changed packages at both bound extremes. +# The release probe has a shared five-minute deadline. +uv run poe validate-python-release --base-ref upstream/main + +# Exhaustive test+typing matrix (slow; use for deliberate dependency-range work or CI) uv run poe validate-dependency-bounds-test -# Defaults to --package "*"; pass a package to scope test mode +# Defaults to --package "*"; scope locally whenever possible. uv run poe validate-dependency-bounds-test --package core # Then expand bounds for one dependency in the target package @@ -66,7 +70,16 @@ uv run poe add-dependency-and-validate-bounds --package core --dependency "` to reduce `uv.lock` merge conflicts. - Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command. - Keep shared tooling and source/type-check support in the root or package `dev` group. Put package-specific test diff --git a/python/pyproject.toml b/python/pyproject.toml index 7ae02b95252..040005cbae0 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -395,10 +395,32 @@ args = [ ] [tool.poe.tasks.validate-dependency-bounds-test] -help = "Run workspace dependency-bound validation in test mode, optionally scoped with -P/--package short names such as `core`." +help = "Run the exhaustive workspace dependency-bound test+typing matrix, optionally scoped with -P/--package short names such as `core`." shell = "python -m scripts.dependencies.validate_dependency_bounds --mode test --package \"$project\"" args = [{ name = "project", default = "*", options = ["-P", "--package"] }] +[tool.poe.tasks.validate-python-release] +help = "Refresh uv.lock, then run lower/upper import probes for changed package metadata on each package closure's minimum Python." +executor = "simple" +shell = """ +command=( + python -m scripts.dependencies.validate_dependency_bounds + --mode release + --base-ref "${base_ref}" + --release-timeout-seconds "${timeout}" +) +if [ -n "${python}" ]; then + command+=(--python "${python}") +fi +"${command[@]}" +""" +interpreter = "bash" +args = [ + { name = "base_ref", options = ["-B", "--base-ref"] }, + { name = "python", default = "", options = ["--python"] }, + { name = "timeout", default = "300", options = ["--timeout-seconds"] }, +] + [tool.poe.tasks.validate-dependency-bounds-project] help = "Validate lower and upper dependency bounds for a -P/--package workspace package, optionally narrowed with -M/--mode and -D/--dependency." shell = """ diff --git a/python/scripts/dependencies/README.md b/python/scripts/dependencies/README.md index e129d67eeda..ae04b8e027c 100644 --- a/python/scripts/dependencies/README.md +++ b/python/scripts/dependencies/README.md @@ -12,10 +12,19 @@ Run the commands below from the `python/` directory. - `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. + - Supports `release`, `test`, `lower`, `upper`, and `both` modes. + - `release` refreshes `uv.lock`, then runs changed packages through fast lock-independent lower/upper import probes. + - `test` runs the exhaustive workspace test+typing compatibility matrix. - `lower`, `upper`, and `both` dispatch to the lower/upper optimizer implementations for one package. +- `_dependency_bounds_release_impl.py` + - Discovers package metadata changed from the selected release base. + - Resolves published runtime dependencies and non-development extras independently of `uv.lock` with both + `lowest-direct` and `highest` strategies. + - Derives the minimum supported Python minor from each changed package's internal editable dependency closure. + - Imports each changed package and records resolved dependency versions in a JSON report. + - Runs probes concurrently under one five-minute deadline. + - `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. @@ -45,6 +54,7 @@ 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-python-release --base-ref upstream/main uv run poe validate-dependency-bounds-test uv run poe validate-dependency-bounds-test --package core uv run poe validate-dependency-bounds-project --mode both --package core --dependency "" @@ -52,7 +62,10 @@ uv run poe validate-dependency-bounds-project --mode both --package core --depen - `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-python-release` is the bounded release gate: it refreshes `uv.lock`, finds changed package metadata, + and probes both dependency-bound extremes without reusing the lockfile. +- `validate-dependency-bounds-test` runs the exhaustive package test+typing matrix and is intentionally not part of + the routine release path. - `validate-dependency-bounds-project` is the single package-scoped task; use `--mode lower`, `--mode upper`, or `--mode both` for the target package/dependency pair. Its `--package` argument defaults to `*`, and `--dependency` is optional, so automation can also use it for repo-wide upper-bound runs. ### GitHub Actions workflows @@ -76,6 +89,7 @@ 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 release --base-ref upstream/main --dry-run python -m scripts.dependencies.validate_dependency_bounds --mode test --package core --dry-run python -m scripts.dependencies.validate_dependency_bounds --mode both --package core --dependencies openai --dry-run python -m scripts.dependencies._dependency_bounds_lower_impl --packages core --dependencies openai --dry-run @@ -89,6 +103,7 @@ Use the direct lower/upper implementation modules mainly for debugging or develo The validators write JSON reports into this folder: - `dependency-bounds-test-results.json` +- `dependency-bounds-release-results.json` - `dependency-lower-bound-results.json` - `dependency-range-results.json` diff --git a/python/scripts/dependencies/_dependency_bounds_release_impl.py b/python/scripts/dependencies/_dependency_bounds_release_impl.py new file mode 100644 index 00000000000..b47d8eb85c6 --- /dev/null +++ b/python/scripts/dependencies/_dependency_bounds_release_impl.py @@ -0,0 +1,574 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff:file-ignore[suspicious-subprocess-import, subprocess-without-shell-equals-true] + +"""Fast, lock-independent dependency-bound probes for Python release cuts.""" + +from __future__ import annotations + +import concurrent.futures +import json +import os +import subprocess +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, cast + +import tomli +from packaging.requirements import InvalidRequirement, Requirement +from packaging.specifiers import SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import Version +from rich import print + +from scripts.task_runner import discover_projects, project_filter_matches + +_PROBE_RESULT_PREFIX = "DEPENDENCY_BOUNDS_RELEASE_RESULT=" +_RESOLUTION_SCENARIOS = (("lower", "lowest-direct"), ("upper", "highest")) + + +@dataclass +class ReleaseProject: + """Published metadata needed to build a release probe.""" + + project_path: Path + package_name: str + requires_python: str + dependencies: tuple[str, ...] + optional_dependencies: dict[str, tuple[str, ...]] + import_modules: tuple[str, ...] + + +@dataclass +class ReleaseProbePlan: + """One changed package and the local projects needed to resolve it.""" + + project_path: Path + package_name: str + editable_specs: tuple[str, ...] + import_modules: tuple[str, ...] + reported_distributions: tuple[str, ...] + python_version: str + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=False)) + + +def _truncate_error(stdout: str, stderr: str, *, max_chars: int = 3000) -> 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 _string_requirements(values: object) -> tuple[str, ...]: + if not isinstance(values, list): + return () + return tuple(value for value in cast(list[object], values) if isinstance(value, str)) + + +def _discover_import_modules(project_path: Path, config: dict[str, Any]) -> tuple[str, ...]: + """Discover top-level import names from the project's build configuration.""" + modules: set[str] = set() + tool = cast(dict[str, Any], config.get("tool", {}) or {}) + + flit = cast(dict[str, Any], tool.get("flit", {}) or {}) + flit_module_config = cast(dict[str, Any], flit.get("module", {}) or {}) + flit_module = flit_module_config.get("name") + if isinstance(flit_module, str) and flit_module: + modules.add(flit_module) + + hatch = cast(dict[str, Any], tool.get("hatch", {}) or {}) + hatch_build = cast(dict[str, Any], hatch.get("build", {}) or {}) + hatch_targets = cast(dict[str, Any], hatch_build.get("targets", {}) or {}) + hatch_wheel = cast(dict[str, Any], hatch_targets.get("wheel", {}) or {}) + hatch_packages = hatch_wheel.get("packages", []) + if isinstance(hatch_packages, list): + for package in cast(list[object], hatch_packages): + if isinstance(package, str) and package: + modules.add(Path(package).name.split(".", 1)[0]) + + setuptools = cast(dict[str, Any], tool.get("setuptools", {}) or {}) + setuptools_packages = setuptools.get("packages", []) + if isinstance(setuptools_packages, list): + for package in cast(list[object], setuptools_packages): + if isinstance(package, str) and package: + modules.add(package.split(".", 1)[0]) + + if not modules: + for candidate in project_path.glob("agent_framework*"): + if candidate.is_dir() and (candidate / "__init__.py").exists(): + modules.add(candidate.name) + elif candidate.is_file() and candidate.suffix == ".py": + modules.add(candidate.stem) + + return tuple(sorted(modules)) + + +def _load_release_project(workspace_root: Path, project_path: Path) -> ReleaseProject: + pyproject_file = workspace_root / project_path / "pyproject.toml" + with pyproject_file.open("rb") as file: + config = tomli.load(file) + + project = cast(dict[str, Any], config.get("project", {}) or {}) + package_name = str(project.get("name", "")).strip() + if not package_name: + raise RuntimeError(f"Missing project.name in {pyproject_file}") + requires_python = str(project.get("requires-python", "")).strip() + if not requires_python: + raise RuntimeError(f"Missing project.requires-python in {pyproject_file}") + + optional_dependencies: dict[str, tuple[str, ...]] = {} + optional_config = cast(dict[str, object], project.get("optional-dependencies", {}) or {}) + for extra_name, requirements in optional_config.items(): + optional_dependencies[extra_name] = _string_requirements(requirements) + + return ReleaseProject( + project_path=project_path, + package_name=package_name, + requires_python=requires_python, + dependencies=_string_requirements(project.get("dependencies", [])), + optional_dependencies=optional_dependencies, + import_modules=_discover_import_modules(pyproject_file.parent, config), + ) + + +def _build_release_project_map(workspace_root: Path) -> dict[str, ReleaseProject]: + project_paths = [Path("."), *sorted(set(discover_projects(workspace_root / "pyproject.toml")))] + projects: dict[str, ReleaseProject] = {} + for project_path in project_paths: + pyproject_file = workspace_root / project_path / "pyproject.toml" + if not pyproject_file.exists(): + continue + project = _load_release_project(workspace_root, project_path) + projects[canonicalize_name(project.package_name)] = project + return projects + + +def _changed_release_project_paths(workspace_root: Path, base_ref: str) -> set[Path]: + command = [ + "git", + "diff", + "--relative", + "--name-only", + "--diff-filter=ACMR", + base_ref, + "--", + "pyproject.toml", + "packages/*/pyproject.toml", + ] + result = subprocess.run(command, cwd=workspace_root, capture_output=True, text=True, check=False) + if result.returncode != 0: + error = _truncate_error(result.stdout, result.stderr) + raise RuntimeError(f"Unable to compare release metadata with {base_ref}.\n{error}") + + project_paths: set[Path] = set() + for line in result.stdout.splitlines(): + changed_file = Path(line.strip()) + if changed_file == Path("pyproject.toml"): + project_paths.add(Path(".")) + elif len(changed_file.parts) == 3 and changed_file.parts[0] == "packages": + project_paths.add(changed_file.parent) + return project_paths + + +def _selected_release_projects( + *, + workspace_root: Path, + projects: dict[str, ReleaseProject], + base_ref: str, + package_filter: str | None, +) -> list[ReleaseProject]: + if package_filter: + selected = [ + project + for project in projects.values() + if project_filter_matches(project.project_path, package_filter, [project.package_name]) + ] + else: + changed_paths = _changed_release_project_paths(workspace_root, base_ref) + selected = [project for project in projects.values() if project.project_path in changed_paths] + + return sorted(selected, key=lambda project: str(project.project_path)) + + +def _requirements_for_extras(project: ReleaseProject, extras: set[str]) -> tuple[str, ...]: + requirements = list(project.dependencies) + for extra_name in sorted(extras): + requirements.extend(project.optional_dependencies.get(extra_name, ())) + return tuple(requirements) + + +def _minimum_python_version(projects: list[ReleaseProject]) -> str: + """Return the lowest Python minor supported by every project in a probe closure.""" + constraints = [project.requires_python for project in projects] + combined = SpecifierSet(",".join(constraints)) + lower_bounds = [ + Version(specifier.version.rstrip(".*")) + for specifier in combined + if specifier.operator in {">", ">=", "~=", "=="} and specifier.version.rstrip(".*") + ] + if not lower_bounds: + package_names = ", ".join(sorted(project.package_name for project in projects)) + raise RuntimeError(f"Unable to derive a Python floor from requires-python for: {package_names}") + + floor = max(lower_bounds) + python_version = f"{floor.major}.{floor.minor}" + first_patch = Version(python_version) + later_patch = Version(f"{python_version}.999999") + if first_patch not in combined and later_patch not in combined: + package_names = ", ".join(sorted(project.package_name for project in projects)) + raise RuntimeError( + f"No Python {python_version} interpreter satisfies the combined requires-python constraints for: " + f"{package_names}" + ) + return python_version + + +def _build_release_probe_plan( + workspace_root: Path, + target: ReleaseProject, + projects: dict[str, ReleaseProject], +) -> ReleaseProbePlan: + """Build the exact internal editable closure for one changed package.""" + target_name = canonicalize_name(target.package_name) + # Development extras are contributor tooling, not runtime compatibility surface. + requested_extras: dict[str, set[str]] = { + target_name: {extra for extra in target.optional_dependencies if extra != "dev"} + } + processed_extras: dict[str, set[str]] = {} + pending = [target_name] + + while pending: + package_name = pending.pop() + project = projects[package_name] + extras = requested_extras[package_name] + if processed_extras.get(package_name) == extras: + continue + processed_extras[package_name] = set(extras) + + for requirement_text in _requirements_for_extras(project, extras): + try: + requirement = Requirement(requirement_text) + except InvalidRequirement: + continue + dependency_name = canonicalize_name(requirement.name) + if dependency_name not in projects: + continue + previous = requested_extras.setdefault(dependency_name, set()) + updated = previous | set(requirement.extras) + if dependency_name not in processed_extras or updated != previous: + requested_extras[dependency_name] = updated + pending.append(dependency_name) + + target_extras = sorted(requested_extras[target_name]) + target_path = (workspace_root / target.project_path).resolve() + target_spec = str(target_path) + if target_extras: + target_spec = f"{target_spec}[{','.join(target_extras)}]" + + editable_specs = [target_spec] + for package_name in sorted(requested_extras): + if package_name == target_name: + continue + editable_specs.append(str((workspace_root / projects[package_name].project_path).resolve())) + + target_requirements = _requirements_for_extras(target, set(target_extras)) + reported_distributions = {canonicalize_name(target.package_name)} + for requirement_text in target_requirements: + try: + reported_distributions.add(canonicalize_name(Requirement(requirement_text).name)) + except InvalidRequirement: + continue + + return ReleaseProbePlan( + project_path=target.project_path, + package_name=target.package_name, + editable_specs=tuple(editable_specs), + import_modules=target.import_modules, + reported_distributions=tuple(sorted(reported_distributions)), + python_version=_minimum_python_version([projects[package_name] for package_name in requested_extras]), + ) + + +def _build_release_probe_command( + plan: ReleaseProbePlan, + *, + resolution: str, + python_override: str | None = None, +) -> list[str]: + probe_script = f""" +import importlib +import json +from importlib.metadata import PackageNotFoundError, version + +modules = {plan.import_modules!r} +distributions = {plan.reported_distributions!r} +for module_name in modules: + importlib.import_module(module_name) +versions = {{}} +for distribution_name in distributions: + try: + versions[distribution_name] = version(distribution_name) + except PackageNotFoundError: + versions[distribution_name] = None +print({_PROBE_RESULT_PREFIX!r} + json.dumps({{"imports": modules, "versions": versions}}, sort_keys=True)) +""" + command = [ + "uv", + "--no-progress", + "run", + "--isolated", + "--no-project", + "--python", + python_override or plan.python_version, + "--resolution", + resolution, + "--prerelease", + "if-necessary-or-explicit", + "--quiet", + ] + for editable_spec in plan.editable_specs: + command.extend(["--with-editable", editable_spec]) + command.extend(["python", "-c", probe_script]) + return command + + +def _parse_probe_payload(stdout: str) -> dict[str, Any] | None: + for line in reversed(stdout.splitlines()): + if line.startswith(_PROBE_RESULT_PREFIX): + try: + payload = json.loads(line.removeprefix(_PROBE_RESULT_PREFIX)) + except json.JSONDecodeError: + return None + return cast(dict[str, Any], payload) if isinstance(payload, dict) else None + return None + + +def _run_release_probe( + plan: ReleaseProbePlan, + *, + scenario_name: str, + resolution: str, + python_override: str | None, + deadline: float, + dry_run: bool, +) -> dict[str, Any]: + python_version = python_override or plan.python_version + command = _build_release_probe_command(plan, resolution=resolution, python_override=python_override) + started = time.monotonic() + if dry_run: + print(f"[cyan]DRY RUN[/cyan] {' '.join(command)}") + return { + "project_path": str(plan.project_path), + "package_name": plan.package_name, + "scenario": scenario_name, + "resolution": resolution, + "python": python_version, + "status": "dry-run", + "duration_seconds": 0.0, + "payload": None, + "error": None, + } + + remaining_seconds = deadline - started + if remaining_seconds <= 0: + return { + "project_path": str(plan.project_path), + "package_name": plan.package_name, + "scenario": scenario_name, + "resolution": resolution, + "python": python_version, + "status": "failed", + "duration_seconds": 0.0, + "payload": None, + "error": "The shared release-validation deadline elapsed before this probe started.", + } + + env = dict(os.environ) + env.pop("VIRTUAL_ENV", None) + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=remaining_seconds, + check=False, + env=env, + ) + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "") + stderr = exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "") + return { + "project_path": str(plan.project_path), + "package_name": plan.package_name, + "scenario": scenario_name, + "resolution": resolution, + "python": python_version, + "status": "failed", + "duration_seconds": round(time.monotonic() - started, 3), + "payload": None, + "error": f"Release probe exceeded the shared deadline.\n{_truncate_error(stdout, stderr)}", + } + + payload = _parse_probe_payload(result.stdout) if result.returncode == 0 else None + error = None + if result.returncode != 0: + error = _truncate_error(result.stdout, result.stderr) + elif payload is None: + error = "Probe completed without emitting its dependency-version payload." + + return { + "project_path": str(plan.project_path), + "package_name": plan.package_name, + "scenario": scenario_name, + "resolution": resolution, + "python": python_version, + "status": "passed" if error is None else "failed", + "duration_seconds": round(time.monotonic() - started, 3), + "payload": payload, + "error": error, + } + + +def _refresh_lockfile( + *, + workspace_root: Path, + deadline: float, + dry_run: bool, +) -> dict[str, Any]: + command = ["uv", "lock", "--prerelease", "if-necessary-or-explicit"] + if dry_run: + print(f"[cyan]DRY RUN[/cyan] {' '.join(command)}") + return {"status": "dry-run", "duration_seconds": 0.0, "error": None} + + started = time.monotonic() + remaining_seconds = deadline - started + if remaining_seconds <= 0: + return { + "status": "failed", + "duration_seconds": 0.0, + "error": "The shared release-validation deadline elapsed before uv.lock refresh started.", + } + try: + result = subprocess.run( + command, + cwd=workspace_root, + capture_output=True, + text=True, + timeout=remaining_seconds, + check=False, + ) + except subprocess.TimeoutExpired as exc: + stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "") + stderr = exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "") + return { + "status": "failed", + "duration_seconds": round(time.monotonic() - started, 3), + "error": f"uv.lock refresh exceeded the shared deadline.\n{_truncate_error(stdout, stderr)}", + } + + error = None if result.returncode == 0 else _truncate_error(result.stdout, result.stderr) + return { + "status": "passed" if error is None else "failed", + "duration_seconds": round(time.monotonic() - started, 3), + "error": error, + } + + +def run_release_mode( + *, + workspace_root: Path, + base_ref: str, + package_filter: str | None, + parallelism: int, + python_override: str | None, + deadline_seconds: int, + dry_run: bool, + output_json: Path, +) -> int: + """Run fast lower/upper release probes for changed package metadata.""" + deadline = time.monotonic() + deadline_seconds + projects = _build_release_project_map(workspace_root) + selected = _selected_release_projects( + workspace_root=workspace_root, + projects=projects, + base_ref=base_ref, + package_filter=package_filter, + ) + if not selected: + print(f"[red]No changed package pyproject.toml files found relative to {base_ref}.[/red]") + return 1 + + lock_result = _refresh_lockfile(workspace_root=workspace_root, deadline=deadline, dry_run=dry_run) + if lock_result["status"] == "failed": + print("[red]uv.lock refresh failed.[/red]") + print(f"[red]{lock_result['error']}[/red]") + return 1 + + plans = [_build_release_probe_plan(workspace_root, project, projects) for project in selected] + work_items = [ + (plan, scenario_name, resolution) for plan in plans for scenario_name, resolution in _RESOLUTION_SCENARIOS + ] + report: dict[str, Any] = { + "started_at": _utc_now(), + "mode": "release", + "workspace_root": str(workspace_root), + "base_ref": base_ref, + "python_override": python_override, + "deadline_seconds": deadline_seconds, + "dry_run": dry_run, + "lockfile": lock_result, + "packages": [str(plan.project_path) for plan in plans], + "probes": [], + "summary": {"probes_total": len(work_items), "probes_passed": 0, "probes_failed": 0}, + } + _write_json(output_json, report) + print( + f"[bold]Running {len(work_items)} lock-independent release probes for {len(plans)} package(s) " + f"with a shared {deadline_seconds}s deadline[/bold]" + ) + print(f"[cyan]Writing dependency-bounds release report to {output_json}[/cyan]") + + max_workers = max(1, min(parallelism, len(work_items))) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit( + _run_release_probe, + plan, + scenario_name=scenario_name, + resolution=resolution, + python_override=python_override, + deadline=deadline, + dry_run=dry_run, + ) + for plan, scenario_name, resolution in work_items + ] + for future in concurrent.futures.as_completed(futures): + result = future.result() + report["probes"].append(result) + if result["status"] in {"passed", "dry-run"}: + report["summary"]["probes_passed"] += 1 + print( + f"[green]{result['project_path']}: {result['scenario']} passed on Python {result['python']} " + f"({result['duration_seconds']:.1f}s)[/green]" + ) + else: + report["summary"]["probes_failed"] += 1 + print(f"[red]{result['project_path']}: {result['scenario']} failed[/red]") + print(f"[red]{result['error']}[/red]") + report["updated_at"] = _utc_now() + _write_json(output_json, report) + + if report["summary"]["probes_failed"]: + print("[bold red]Release dependency-bound validation failed.[/bold red]") + return 1 + print("[bold green]Release dependency-bound validation completed successfully.[/bold green]") + return 0 diff --git a/python/scripts/dependencies/tests/test_dependency_bounds_release.py b/python/scripts/dependencies/tests/test_dependency_bounds_release.py new file mode 100644 index 00000000000..88913a10b09 --- /dev/null +++ b/python/scripts/dependencies/tests/test_dependency_bounds_release.py @@ -0,0 +1,217 @@ +# Copyright (c) Microsoft. All rights reserved. + +import json +import sys +from pathlib import Path +from subprocess import CompletedProcess + +import pytest + +from scripts.dependencies._dependency_bounds_release_impl import ( + _PROBE_RESULT_PREFIX, + ReleaseProbePlan, + _build_release_probe_command, + _build_release_probe_plan, + _build_release_project_map, + _changed_release_project_paths, + _parse_probe_payload, + run_release_mode, +) +from scripts.dependencies.validate_dependency_bounds import main + + +def _write_project(path: Path, content: str) -> None: + path.mkdir(parents=True, exist_ok=True) + (path / "pyproject.toml").write_text(content) + + +def test_release_probe_uses_only_the_required_internal_dependency_closure(tmp_path: Path) -> None: + _write_project( + tmp_path, + """ +[project] +name = "agent-framework" +version = "1.2.0" +requires-python = ">=3.10" +dependencies = ["agent-framework-core[all]==1.2.0"] + +[tool.uv.workspace] +members = ["packages/*"] + +[tool.flit.module] +name = "agent_framework_meta" +""", + ) + _write_project( + tmp_path / "packages/core", + """ +[project] +name = "agent-framework-core" +version = "1.2.0" +requires-python = ">=3.10" +dependencies = ["pydantic>=2,<3"] + +[project.optional-dependencies] +all = ["agent-framework-connector>=1,<2"] +dev = ["pytest>=9"] + +[tool.flit.module] +name = "agent_framework" +""", + ) + _write_project( + tmp_path / "packages/connector", + """ +[project] +name = "agent-framework-connector" +version = "1.0.0" +requires-python = ">=3.10" +dependencies = ["agent-framework-core>=1,<2", "httpx>=0.27,<1"] + +[tool.flit.module] +name = "agent_framework_connector" +""", + ) + _write_project( + tmp_path / "packages/provider", + """ +[project] +name = "agent-framework-provider" +version = "1.0.0" +requires-python = ">=3.11" +dependencies = ["agent-framework-core>=1,<2", "openai>=2,<3"] + +[tool.flit.module] +name = "agent_framework_provider" +""", + ) + + projects = _build_release_project_map(tmp_path) + provider_plan = _build_release_probe_plan(tmp_path, projects["agent-framework-provider"], projects) + provider_editables = "\n".join(provider_plan.editable_specs) + + assert "packages/provider" in provider_editables + assert "packages/core" in provider_editables + assert "packages/connector" not in provider_editables + assert provider_plan.python_version == "3.11" + + root_plan = _build_release_probe_plan(tmp_path, projects["agent-framework"], projects) + root_editables = "\n".join(root_plan.editable_specs) + assert "packages/core" in root_editables + assert "packages/connector" in root_editables + assert "pytest" not in root_plan.reported_distributions + assert root_plan.python_version == "3.10" + + +def test_release_probe_command_is_lock_independent_and_uses_bound_resolution(tmp_path: Path) -> None: + plan = ReleaseProbePlan( + project_path=Path("packages/openai"), + package_name="agent-framework-openai", + editable_specs=(str(tmp_path / "packages/openai"), str(tmp_path / "packages/core")), + import_modules=("agent_framework_openai",), + reported_distributions=("agent-framework-openai", "openai"), + python_version="3.11", + ) + + command = _build_release_probe_command(plan, resolution="lowest-direct") + + assert "--no-project" in command + assert command[command.index("--resolution") + 1] == "lowest-direct" + assert command[command.index("--python") + 1] == "3.11" + assert command[command.index("--prerelease") + 1] == "if-necessary-or-explicit" + assert command.count("--with-editable") == 2 + assert "pytest" not in command + assert "pyright" not in command + + overridden_command = _build_release_probe_command(plan, resolution="highest", python_override="3.12") + assert overridden_command[overridden_command.index("--python") + 1] == "3.12" + + +def test_changed_release_projects_are_relative_to_python_workspace(tmp_path: Path, monkeypatch) -> None: + def fake_run(*args, **kwargs) -> CompletedProcess[str]: + return CompletedProcess( + args=args[0], + returncode=0, + stdout="pyproject.toml\npackages/core/pyproject.toml\nREADME.md\n", + stderr="", + ) + + monkeypatch.setattr("scripts.dependencies._dependency_bounds_release_impl.subprocess.run", fake_run) + + assert _changed_release_project_paths(tmp_path, "upstream/main") == {Path("."), Path("packages/core")} + + +def test_parse_probe_payload_uses_the_last_valid_marker() -> None: + first_payload = json.dumps({"versions": {"openai": "2.25.0"}}) + last_payload = {"imports": ["agent_framework_openai"], "versions": {"openai": "2.47.0"}} + stdout = "\n".join(( + f"{_PROBE_RESULT_PREFIX}{first_payload}", + "unrelated subprocess output", + f"{_PROBE_RESULT_PREFIX}{json.dumps(last_payload)}", + )) + + assert _parse_probe_payload(stdout) == last_payload + assert _parse_probe_payload(f"{_PROBE_RESULT_PREFIX}not-json") is None + assert _parse_probe_payload(f"{_PROBE_RESULT_PREFIX}[]") is None + assert _parse_probe_payload("unrelated subprocess output") is None + + +def test_run_release_mode_dry_run_uses_selected_package_python_floor(tmp_path: Path) -> None: + _write_project( + tmp_path, + """ +[project] +name = "agent-framework" +version = "1.2.0" +requires-python = ">=3.10" +dependencies = [] + +[tool.uv.workspace] +members = ["packages/*"] + +[tool.flit.module] +name = "agent_framework_meta" +""", + ) + _write_project( + tmp_path / "packages/provider", + """ +[project] +name = "agent-framework-provider" +version = "1.0.0" +requires-python = ">=3.11" +dependencies = [] + +[tool.flit.module] +name = "agent_framework_provider" +""", + ) + output_json = tmp_path / "release-results.json" + + exit_code = run_release_mode( + workspace_root=tmp_path, + base_ref="HEAD", + package_filter="provider", + parallelism=2, + python_override=None, + deadline_seconds=300, + dry_run=True, + output_json=output_json, + ) + + assert exit_code == 0 + report = json.loads(output_json.read_text()) + assert report["python_override"] is None + assert report["summary"] == {"probes_total": 2, "probes_passed": 2, "probes_failed": 0} + assert {probe["python"] for probe in report["probes"]} == {"3.11"} + assert {probe["status"] for probe in report["probes"]} == {"dry-run"} + + +def test_release_mode_rejects_blank_base_ref(monkeypatch, capsys) -> None: + monkeypatch.setattr(sys, "argv", ["validate_dependency_bounds", "--mode", "release", "--base-ref", " "]) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 2 + assert "release mode requires --base-ref" in capsys.readouterr().err diff --git a/python/scripts/dependencies/validate_dependency_bounds.py b/python/scripts/dependencies/validate_dependency_bounds.py index 3ba703c807f..0f7d26c7052 100644 --- a/python/scripts/dependencies/validate_dependency_bounds.py +++ b/python/scripts/dependencies/validate_dependency_bounds.py @@ -1,9 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. -# ruff: noqa: S404, S603 +# ruff:file-ignore[suspicious-subprocess-import, subprocess-without-shell-equals-true] """Unified dependency-bound validation entrypoint. Modes: +- release: run fast lock-independent lower/upper import probes for changed release packages. - 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. @@ -28,6 +29,7 @@ import tomli from rich import print +from scripts.dependencies._dependency_bounds_release_impl import run_release_mode from scripts.dependencies._dependency_bounds_runtime import ( extend_command_with_runtime_tools, extend_command_with_task, @@ -363,15 +365,16 @@ 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, " + "Unified dependency-bound workflow. Use mode=release for fast release sanity probes, " + "mode=test for the exhaustive workspace lower+upper matrix, " "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).", + choices=("release", "test", "lower", "upper", "both"), + help="Execution mode: release/test gates or lower/upper/both bound expansion.", ) parser.add_argument( "--package", @@ -422,11 +425,49 @@ def main() -> None: default="scripts/dependencies/dependency-bounds-test-results.json", help="Output report path for test mode.", ) + parser.add_argument( + "--base-ref", + default=None, + help="Git base used to discover changed package metadata in release mode (required unless --package is set).", + ) + parser.add_argument( + "--python", + default=None, + help="Optional Python override for release probes (defaults to each package closure's requires-python floor).", + ) + parser.add_argument( + "--release-timeout-seconds", + type=int, + default=300, + help="Shared wall-clock deadline for all release probes.", + ) + parser.add_argument( + "--release-output-json", + default="scripts/dependencies/dependency-bounds-release-results.json", + help="Output report path for release 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 == "release": + base_ref = args.base_ref.strip() if args.base_ref else "" + python_override = args.python.strip() if args.python else None + if not base_ref and normalized_package is None: + parser.error("release mode requires --base-ref unless --package selects one package explicitly") + exit_code = run_release_mode( + workspace_root=workspace_root, + base_ref=base_ref or "HEAD", + package_filter=normalized_package, + parallelism=args.parallelism, + python_override=python_override, + deadline_seconds=args.release_timeout_seconds, + dry_run=args.dry_run, + output_json=(workspace_root / args.release_output_json).resolve(), + ) + raise SystemExit(exit_code) + if args.mode == "test": exit_code = _run_test_mode( workspace_root=workspace_root,