diff --git a/python/DEV_SETUP.md b/python/DEV_SETUP.md index c19da35a64e..479d5f47749 100644 --- a/python/DEV_SETUP.md +++ b/python/DEV_SETUP.md @@ -384,7 +384,8 @@ uv run poe check -S ``` #### `validate-dependency-bounds-test` -Run workspace-wide dependency compatibility gates at lower and upper resolutions. This runs test + pyright across all packages and stops on first failure: +Run workspace-wide dependency compatibility gates at lower and upper resolutions. This runs tests plus Pyright (or a +package-specific `dependency-pyright` task) across all packages and stops on first failure: ```bash uv run poe validate-dependency-bounds-test # Defaults to --package "*"; pass a package to scope test mode diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 31a968dec88..b943b92dab6 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.11.0,<2", "ag-ui-protocol>=0.1.19,<0.2", - "fastapi>=0.121.0,<0.138.1", + "fastapi>=0.121.0,<0.140.0", "sse-starlette>=3.4.5,<4", "uvicorn[standard]>=0.30.0,<1" ] @@ -35,6 +35,11 @@ dev = [ "httpx==0.28.1", ] +[dependency-groups] +test = [ + "agent-framework-orchestrations", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index ac535dc693e..9732105556f 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -128,6 +128,10 @@ exclude_dirs = ["tests"] executor.type = "uv" include = "../../shared_tasks.toml" +[tool.poe.tasks.dependency-pyright] +help = "Run Pyright over core implementation files for isolated dependency validation." +cmd = "pyright --project pyrightconfig.dependency.json" + [tool.poe.tasks.mypy] help = "Run MyPy for this package." cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework" diff --git a/python/packages/core/pyrightconfig.dependency.json b/python/packages/core/pyrightconfig.dependency.json new file mode 100644 index 00000000000..2cc3d95b21f --- /dev/null +++ b/python/packages/core/pyrightconfig.dependency.json @@ -0,0 +1,11 @@ +{ + "extends": "../../pyproject.toml", + "include": [ + "agent_framework/*.py", + "agent_framework/_harness", + "agent_framework/_workflows" + ], + "exclude": [ + "agent_framework/_harness/_agent.py" + ] +} diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index c01c0f78c1c..979c18dca80 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -2983,6 +2983,8 @@ async def test_persist_only_history_provider_still_injects_inmemory( async def test_shared_local_storage_cross_provider_responses_history_does_not_leak_fc_id() -> None: """Responses-specific replay metadata should stay local to Responses when session storage is shared.""" + pytest.importorskip("agent_framework_openai") + from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_message import ChatCompletionMessage diff --git a/python/packages/core/tests/core/test_azure_namespace.py b/python/packages/core/tests/core/test_azure_namespace.py index 9960b77ac95..d4e4b8317b9 100644 --- a/python/packages/core/tests/core/test_azure_namespace.py +++ b/python/packages/core/tests/core/test_azure_namespace.py @@ -1,9 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_azure_cosmos import CosmosHistoryProvider +import pytest import agent_framework.azure as azure +CosmosHistoryProvider = pytest.importorskip("agent_framework_azure_cosmos").CosmosHistoryProvider + def test_azure_namespace_exposes_cosmos_history_provider() -> None: assert azure.CosmosHistoryProvider is CosmosHistoryProvider diff --git a/python/packages/core/tests/core/test_foundry_namespace.py b/python/packages/core/tests/core/test_foundry_namespace.py index faadc99239a..f29988b235c 100644 --- a/python/packages/core/tests/core/test_foundry_namespace.py +++ b/python/packages/core/tests/core/test_foundry_namespace.py @@ -1,13 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. import pytest -from agent_framework_foundry import FoundryChatClient, FoundryMemoryProvider -from agent_framework_foundry_hosting import ResponsesHostServer -from agent_framework_foundry_local import FoundryLocalClient import agent_framework.azure as azure import agent_framework.foundry as foundry +_foundry = pytest.importorskip("agent_framework_foundry") +_foundry_hosting = pytest.importorskip("agent_framework_foundry_hosting") +_foundry_local = pytest.importorskip("agent_framework_foundry_local") + +FoundryChatClient = _foundry.FoundryChatClient +FoundryMemoryProvider = _foundry.FoundryMemoryProvider +ResponsesHostServer = _foundry_hosting.ResponsesHostServer +FoundryLocalClient = _foundry_local.FoundryLocalClient + def test_foundry_namespace_exposes_cloud_and_local_symbols() -> None: assert foundry.FoundryChatClient is FoundryChatClient diff --git a/python/packages/core/tests/core/test_harness_agent.py b/python/packages/core/tests/core/test_harness_agent.py index fb8cab0506e..36a1b5f99cd 100644 --- a/python/packages/core/tests/core/test_harness_agent.py +++ b/python/packages/core/tests/core/test_harness_agent.py @@ -6,11 +6,10 @@ import warnings from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import patch import pytest -from agent_framework_tools.shell import ShellResult from agent_framework import ( AgentSession, @@ -40,6 +39,9 @@ from agent_framework._sessions import ContextProvider, PerServiceCallHistoryPersistingMiddleware from agent_framework._tools import FunctionInvocationLayer +if TYPE_CHECKING: + from agent_framework_tools.shell import ShellResult + class _FakeChatClient(BaseChatClient[ChatOptions[Any]]): """Minimal chat client stub for testing assembly.""" @@ -867,6 +869,8 @@ async def close(self) -> None: pass async def run(self, command: str, *, timeout: float | None = None) -> ShellResult: + from agent_framework_tools.shell import ShellResult + return ShellResult(stdout="", stderr="", exit_code=0, duration_ms=0) async def __aenter__(self) -> _FakeShellTool: diff --git a/python/packages/core/tests/workflow/test_full_conversation.py b/python/packages/core/tests/workflow/test_full_conversation.py index fbb9ac4eeee..9d5725d4658 100644 --- a/python/packages/core/tests/workflow/test_full_conversation.py +++ b/python/packages/core/tests/workflow/test_full_conversation.py @@ -27,7 +27,6 @@ executor, handler, ) -from agent_framework.orchestrations import SequentialBuilder class _SimpleAgent(BaseAgent): @@ -254,6 +253,9 @@ async def _run() -> AgentResponse: async def test_sequential_adapter_uses_full_conversation() -> None: + pytest.importorskip("agent_framework_orchestrations") + from agent_framework.orchestrations import SequentialBuilder + # Arrange: two streaming agents; the second records what it receives a1 = _CaptureAgent(id="agent1", name="A1", reply_text="A1 reply") a2 = _CaptureAgent(id="agent2", name="A2", reply_text="A2 reply") @@ -273,6 +275,9 @@ async def test_sequential_adapter_uses_full_conversation() -> None: async def test_sequential_handoff_preserves_function_call_for_non_reasoning_model() -> None: + pytest.importorskip("agent_framework_orchestrations") + from agent_framework.orchestrations import SequentialBuilder + # Arrange: non-reasoning agent emits function_call + function_result + summary first = _ToolHistoryAgent( id="tool_history_agent", diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index 83275da8589..93c6c93d580 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -5,6 +5,8 @@ import pytest +pytest.importorskip("agent_framework_orchestrations") + from agent_framework import ( AgentResponse, AgentResponseUpdate, diff --git a/python/scripts/dependencies/README.md b/python/scripts/dependencies/README.md index ae04b8e027c..8c577d64299 100644 --- a/python/scripts/dependencies/README.md +++ b/python/scripts/dependencies/README.md @@ -43,6 +43,12 @@ Run the commands below from the `python/` directory. - `_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. + - Resolves internal editable packages from the target's enabled groups and extras, following only base transitive + dependencies and explicitly requested extras so aggregate surfaces such as `core[all]` do not leak into unrelated + package probes. + - Uses a package-defined `dependency-pyright` task when present, allowing dependency probes to type-check the + package implementation without requiring optional lazy namespace packages. Normal repository Pyright tasks are + unchanged. These tasks reuse the root workspace `test` dependency requirements inside their isolated environment. ## Common entrypoints diff --git a/python/scripts/dependencies/_dependency_bounds_lower_impl.py b/python/scripts/dependencies/_dependency_bounds_lower_impl.py index 1dcaeb4862e..3cb40ca3703 100644 --- a/python/scripts/dependencies/_dependency_bounds_lower_impl.py +++ b/python/scripts/dependencies/_dependency_bounds_lower_impl.py @@ -21,17 +21,19 @@ from urllib import request as urllib_request import tomli -from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version from rich import print from scripts.dependencies._dependency_bounds_runtime import ( extend_command_with_runtime_tools, extend_command_with_task, + load_workspace_package_configs, + resolve_internal_editables, ) from scripts.task_runner import discover_projects, extract_poe_tasks, project_filter_matches -CHECK_TASK_PRIORITY = ("check", "typing", "pyright", "mypy", "lint") +CHECK_TASK_PRIORITY = ("dependency-pyright", "check", "typing", "pyright", "mypy", "lint") REQ_PATTERN = r"^\s*([A-Za-z0-9_.-]+(?:\[[^\]]+\])?)\s*(.*?)\s*$" SECTION_HEADER_PATTERN = re.compile(r"^\s*\[([^\]]+)\]\s*$") INLINE_ARRAY_ASSIGNMENT_PATTERN = re.compile( @@ -422,13 +424,6 @@ def _load_package_name(pyproject_file: Path) -> str: return str(data["project"]["name"]) -def _extract_requirement_name(requirement: str) -> str | None: - try: - return Requirement(requirement).name.lower() - except InvalidRequirement: - return None - - def _select_validation_tasks(available_tasks: set[str]) -> list[str]: check_task = next((task for task in CHECK_TASK_PRIORITY if task in available_tasks), None) tasks: list[str] = [] @@ -439,62 +434,6 @@ def _select_validation_tasks(available_tasks: set[str]) -> list[str]: return tasks -def _build_workspace_package_map(workspace_root: Path) -> dict[str, Path]: - package_map: dict[str, Path] = {} - for pyproject_file in sorted((workspace_root / "packages").glob("*/pyproject.toml")): - with pyproject_file.open("rb") as f: - data = tomli.load(f) - package_name = str(data.get("project", {}).get("name", "")).strip() - if package_name: - package_map[package_name] = pyproject_file.parent - return package_map - - -def _build_internal_graph(workspace_root: Path, package_map: dict[str, Path]) -> dict[str, set[str]]: - graph: dict[str, set[str]] = {} - for package_name, package_path in package_map.items(): - pyproject_file = package_path / "pyproject.toml" - with pyproject_file.open("rb") as f: - data = tomli.load(f) - project = data.get("project", {}) or {} - dependencies: list[str] = list(project.get("dependencies", []) or []) - for values in (project.get("optional-dependencies", {}) or {}).values(): - dependencies.extend([value for value in (values or []) if isinstance(value, str)]) - for values in (data.get("dependency-groups", {}) or {}).values(): - dependencies.extend([value for value in (values or []) if isinstance(value, str)]) - internal = set() - for dependency in dependencies: - dependency_name = _extract_requirement_name(dependency) - if dependency_name is None: - continue - if dependency_name.startswith("agent-framework"): - for candidate_name in package_map: - if candidate_name.lower() == dependency_name: - internal.add(candidate_name) - break - graph[package_name] = internal - return graph - - -def _resolve_internal_editables( - package_name: str, package_map: dict[str, Path], graph: dict[str, set[str]] -) -> list[Path]: - visited: set[str] = set() - stack = [package_name] - results: set[Path] = set() - while stack: - current = stack.pop() - if current in visited: - continue - visited.add(current) - for dependency_name in graph.get(current, set()): - dependency_path = package_map.get(dependency_name) - if dependency_path and dependency_name != package_name: - results.add(dependency_path.resolve()) - stack.append(dependency_name) - return sorted(results) - - def _collect_targets( pyproject_file: Path, *, @@ -648,7 +587,7 @@ def _run_tasks( if dependency_pin is not None: dependency_name, dependency_version = dependency_pin command.extend(["--with", f"{dependency_name}=={dependency_version}"]) - extend_command_with_task(command, task_name) + extend_command_with_task(command, task_name, workspace_root=workspace_root) try: result = subprocess.run( command, @@ -1024,8 +963,7 @@ def main() -> None: output_json_path = (workspace_root / args.output_json).resolve() # Phase 1: prepare shared workspace metadata and collect package execution plans. - package_map = _build_workspace_package_map(workspace_root) - internal_graph = _build_internal_graph(workspace_root, package_map) + workspace_packages = load_workspace_package_configs(workspace_root) lock_versions = _load_lock_versions(workspace_root) catalog = VersionCatalog(lock_versions=lock_versions, source=args.version_source) @@ -1036,11 +974,15 @@ def main() -> None: print(f"[yellow]Skipping {project_path}: missing pyproject.toml[/yellow]") continue package_name = _load_package_name(pyproject_file) - 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 {} + workspace_package = workspace_packages[str(canonicalize_name(package_name))] + dependency_group_names = sorted(workspace_package.dependency_groups) + include_dev_extra = "dev" in workspace_package.optional_dependencies + optional_extra_names = sorted( + name for name in workspace_package.optional_dependencies if name not in {"all", "dev"} + ) + selected_extra_names = list(optional_extra_names) + if include_dev_extra: + selected_extra_names.append("dev") # Reuse the shared selector matcher so direct optimizer runs accept the # same short-name package filters as the contributor-facing Poe tasks. if package_filters and not any( @@ -1052,10 +994,15 @@ def main() -> None: project_path=project_path, package_name=package_name, pyproject_path=pyproject_file, - internal_editables=_resolve_internal_editables(package_name, package_map, internal_graph), - dependency_groups=sorted(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, + workspace_packages, + dependency_groups=dependency_group_names, + optional_extras=selected_extra_names, + ), + dependency_groups=dependency_group_names, + include_dev_extra=include_dev_extra, + optional_extras=optional_extra_names, ) ) diff --git a/python/scripts/dependencies/_dependency_bounds_runtime.py b/python/scripts/dependencies/_dependency_bounds_runtime.py index 73cbb2f1d3f..c19afcf8647 100644 --- a/python/scripts/dependencies/_dependency_bounds_runtime.py +++ b/python/scripts/dependencies/_dependency_bounds_runtime.py @@ -5,11 +5,15 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence +from dataclasses import dataclass from functools import lru_cache from pathlib import Path +from typing import cast import tomli from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name _TOOL_REQUIREMENT_NAMES = { "mypy", @@ -35,22 +39,138 @@ "import subprocess, sys; " "raise SystemExit(subprocess.call([sys.executable, '-m', 'pyright', '--pythonpath', sys.executable]))" ) +_DEPENDENCY_PYRIGHT_COMMAND = ( + "import subprocess, sys; " + "raise SystemExit(subprocess.call([" + "sys.executable, '-m', 'pyright', '--project', 'pyrightconfig.dependency.json', " + "'--pythonpath', sys.executable]))" +) + + +@lru_cache(maxsize=16) +def load_dependency_group_requirements(workspace_root: str, group_name: str) -> tuple[str, ...]: + """Load string requirements from one root workspace dependency group.""" + pyproject_path = Path(workspace_root) / "pyproject.toml" + data = cast(dict[str, object], tomli.loads(pyproject_path.read_text())) + dependency_groups = cast(dict[str, object], data.get("dependency-groups", {}) or {}) + return _string_requirements(dependency_groups.get(group_name, [])) + + +@dataclass(frozen=True) +class WorkspacePackageConfig: + """Dependency metadata needed to resolve internal editable packages.""" + + project_path: Path + dependencies: tuple[str, ...] + optional_dependencies: Mapping[str, tuple[str, ...]] + dependency_groups: Mapping[str, tuple[str, ...]] + + +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 load_workspace_package_configs(workspace_root: Path) -> dict[str, WorkspacePackageConfig]: + """Load workspace package dependency metadata keyed by normalized package name.""" + packages: dict[str, WorkspacePackageConfig] = {} + for pyproject_file in sorted((workspace_root / "packages").glob("*/pyproject.toml")): + with pyproject_file.open("rb") as file: + config = cast(dict[str, object], tomli.load(file)) + + project = cast(dict[str, object], config.get("project", {}) or {}) + package_name = str(project.get("name", "")).strip() + if not package_name: + continue + + normalized_name = str(canonicalize_name(package_name)) + if normalized_name in packages: + raise RuntimeError(f"Duplicate workspace package name: {package_name}") + + optional_config = cast(dict[str, object], project.get("optional-dependencies", {}) or {}) + optional_dependencies: dict[str, tuple[str, ...]] = { + str(canonicalize_name(extra_name)): _string_requirements(requirements) + for extra_name, requirements in optional_config.items() + } + dependency_group_config = cast(dict[str, object], config.get("dependency-groups", {}) or {}) + dependency_groups: dict[str, tuple[str, ...]] = { + group_name: _string_requirements(requirements) + for group_name, requirements in dependency_group_config.items() + } + packages[normalized_name] = WorkspacePackageConfig( + project_path=pyproject_file.parent, + dependencies=_string_requirements(project.get("dependencies", [])), + optional_dependencies=optional_dependencies, + dependency_groups=dependency_groups, + ) + return packages + + +def resolve_internal_editables( + package_name: str, + packages: Mapping[str, WorkspacePackageConfig], + *, + dependency_groups: Sequence[str], + optional_extras: Sequence[str], +) -> list[Path]: + """Resolve the internal editable closure for the target package's selected dependency surface.""" + target_name = str(canonicalize_name(package_name)) + if target_name not in packages: + raise ValueError(f"Unknown workspace package: {package_name}") + + requested_extras: dict[str, set[str]] = { + target_name: {str(canonicalize_name(extra_name)) for extra_name in optional_extras} + } + processed_extras: dict[str, set[str]] = {} + pending = [target_name] + editables: set[Path] = set() + + while pending: + current_name = pending.pop() + current_extras = requested_extras[current_name] + if processed_extras.get(current_name) == current_extras: + continue + processed_extras[current_name] = set(current_extras) + + package = packages[current_name] + requirements = list(package.dependencies) + for extra_name in sorted(current_extras): + requirements.extend(package.optional_dependencies.get(extra_name, ())) + if current_name == target_name: + for group_name in dependency_groups: + requirements.extend(package.dependency_groups.get(group_name, ())) + + for requirement_text in requirements: + try: + requirement = Requirement(requirement_text) + except InvalidRequirement: + continue + + dependency_name = str(canonicalize_name(requirement.name)) + dependency = packages.get(dependency_name) + if dependency is None: + continue + + if dependency_name != target_name: + editables.add(dependency.project_path.resolve()) + + previous_extras = requested_extras.setdefault(dependency_name, set()) + updated_extras = previous_extras | {str(canonicalize_name(extra)) for extra in requirement.extras} + if dependency_name not in processed_extras or updated_extras != previous_extras: + requested_extras[dependency_name] = updated_extras + pending.append(dependency_name) + + return sorted(editables) @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 + for requirement in load_dependency_group_requirements(workspace_root, "dev"): try: parsed = Requirement(requirement) except InvalidRequirement: @@ -69,11 +189,16 @@ def extend_command_with_runtime_tools(command: list[str], workspace_root: Path) command.extend(["--with", requirement]) -def extend_command_with_task(command: list[str], task_name: str) -> None: +def extend_command_with_task(command: list[str], task_name: str, *, workspace_root: Path) -> None: """Append the command needed to execute one validation task.""" if task_name == "pyright": command.extend(["python", "-c", _PYRIGHT_COMMAND]) return + if task_name == "dependency-pyright": + for requirement in load_dependency_group_requirements(str(workspace_root.resolve()), "test"): + command.extend(["--with", requirement]) + command.extend(["python", "-c", _DEPENDENCY_PYRIGHT_COMMAND]) + return command.extend(["python", "-m", "poethepoet", task_name]) diff --git a/python/scripts/dependencies/_dependency_bounds_upper_impl.py b/python/scripts/dependencies/_dependency_bounds_upper_impl.py index 9dab4de1d9c..a2c5e29a71f 100644 --- a/python/scripts/dependencies/_dependency_bounds_upper_impl.py +++ b/python/scripts/dependencies/_dependency_bounds_upper_impl.py @@ -23,19 +23,22 @@ import tomli from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version from rich import print from scripts.dependencies._dependency_bounds_runtime import ( extend_command_with_runtime_tools, extend_command_with_task, + load_workspace_package_configs, next_zero_major_minor_boundary, + resolve_internal_editables, ) from scripts.task_runner import discover_projects, extract_poe_tasks, project_filter_matches logger = logging.getLogger(__name__) -CHECK_TASK_PRIORITY = ("check", "typing", "pyright", "mypy", "lint") +CHECK_TASK_PRIORITY = ("dependency-pyright", "check", "typing", "pyright", "mypy", "lint") AZURE_MONITOR_OPENTELEMETRY = "azure-monitor-opentelemetry" OPENTELEMETRY_SDK = "opentelemetry-sdk" VALIDATION_TOOL_DEV_PINS = frozenset({"mypy", "pyrefly", "pyright", "ruff", "ty", "zuban"}) @@ -556,13 +559,6 @@ def _load_package_name(pyproject_file: Path) -> str: return str(data["project"]["name"]) -def _extract_requirement_name(requirement: str) -> str | None: - try: - return Requirement(requirement).name.lower() - except InvalidRequirement: - return None - - def _select_validation_tasks(available_tasks: set[str]) -> list[str]: check_task = next((task for task in CHECK_TASK_PRIORITY if task in available_tasks), None) tasks: list[str] = [] @@ -573,62 +569,6 @@ def _select_validation_tasks(available_tasks: set[str]) -> list[str]: return tasks -def _build_workspace_package_map(workspace_root: Path) -> dict[str, Path]: - package_map: dict[str, Path] = {} - for pyproject_file in sorted((workspace_root / "packages").glob("*/pyproject.toml")): - with pyproject_file.open("rb") as f: - data = tomli.load(f) - package_name = str(data.get("project", {}).get("name", "")).strip() - if package_name: - package_map[package_name] = pyproject_file.parent - return package_map - - -def _build_internal_graph(workspace_root: Path, package_map: dict[str, Path]) -> dict[str, set[str]]: - graph: dict[str, set[str]] = {} - for package_name, package_path in package_map.items(): - pyproject_file = package_path / "pyproject.toml" - with pyproject_file.open("rb") as f: - data = tomli.load(f) - project = data.get("project", {}) or {} - dependencies: list[str] = list(project.get("dependencies", []) or []) - for values in (project.get("optional-dependencies", {}) or {}).values(): - dependencies.extend([value for value in (values or []) if isinstance(value, str)]) - for values in (data.get("dependency-groups", {}) or {}).values(): - dependencies.extend([value for value in (values or []) if isinstance(value, str)]) - internal = set() - for dependency in dependencies: - dependency_name = _extract_requirement_name(dependency) - if dependency_name is None: - continue - if dependency_name.startswith("agent-framework"): - for candidate_name in package_map: - if candidate_name.lower() == dependency_name: - internal.add(candidate_name) - break - graph[package_name] = internal - return graph - - -def _resolve_internal_editables( - package_name: str, package_map: dict[str, Path], graph: dict[str, set[str]] -) -> list[Path]: - visited: set[str] = set() - stack = [package_name] - results: set[Path] = set() - while stack: - current = stack.pop() - if current in visited: - continue - visited.add(current) - for dependency_name in graph.get(current, set()): - dependency_path = package_map.get(dependency_name) - if dependency_path and dependency_name != package_name: - results.add(dependency_path.resolve()) - stack.append(dependency_name) - return sorted(results) - - def _collect_targets( pyproject_file: Path, *, @@ -825,7 +765,7 @@ def _run_tasks( if dependency_pin is not None: dependency_name, dependency_version = dependency_pin command.extend(["--with", f"{dependency_name}=={dependency_version}"]) - extend_command_with_task(command, task_name) + extend_command_with_task(command, task_name, workspace_root=workspace_root) try: result = subprocess.run( command, @@ -1222,8 +1162,7 @@ def main() -> None: dependency_filters = {name.lower() for name in args.dependencies} if args.dependencies else None output_json_path = (workspace_root / args.output_json).resolve() - package_map = _build_workspace_package_map(workspace_root) - internal_graph = _build_internal_graph(workspace_root, package_map) + workspace_packages = load_workspace_package_configs(workspace_root) lock_versions = _load_lock_versions(workspace_root) catalog = VersionCatalog(lock_versions=lock_versions, source=args.version_source) @@ -1234,11 +1173,15 @@ def main() -> None: print(f"[yellow]Skipping {project_path}: missing pyproject.toml[/yellow]") continue package_name = _load_package_name(pyproject_file) - 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 {} + workspace_package = workspace_packages[str(canonicalize_name(package_name))] + dependency_group_names = sorted(workspace_package.dependency_groups) + include_dev_extra = "dev" in workspace_package.optional_dependencies + optional_extra_names = sorted( + name for name in workspace_package.optional_dependencies if name not in {"all", "dev"} + ) + selected_extra_names = list(optional_extra_names) + if include_dev_extra: + selected_extra_names.append("dev") # Reuse the shared selector matcher so direct optimizer runs accept the # same short-name package filters as the contributor-facing Poe tasks. if package_filters and not any( @@ -1250,10 +1193,15 @@ def main() -> None: project_path=project_path, package_name=package_name, pyproject_path=pyproject_file, - internal_editables=_resolve_internal_editables(package_name, package_map, internal_graph), - dependency_groups=sorted(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, + workspace_packages, + dependency_groups=dependency_group_names, + optional_extras=selected_extra_names, + ), + dependency_groups=dependency_group_names, + include_dev_extra=include_dev_extra, + optional_extras=optional_extra_names, ) ) diff --git a/python/scripts/dependencies/tests/test_dependency_bounds_runtime.py b/python/scripts/dependencies/tests/test_dependency_bounds_runtime.py new file mode 100644 index 00000000000..12e184057c5 --- /dev/null +++ b/python/scripts/dependencies/tests/test_dependency_bounds_runtime.py @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from scripts.dependencies._dependency_bounds_lower_impl import _select_validation_tasks as _select_lower_tasks +from scripts.dependencies._dependency_bounds_runtime import ( + extend_command_with_task, + load_workspace_package_configs, + resolve_internal_editables, +) +from scripts.dependencies._dependency_bounds_upper_impl import _select_validation_tasks as _select_upper_tasks +from scripts.dependencies.validate_dependency_bounds import _build_test_plans + + +def _write_project(path: Path, content: str) -> None: + path.mkdir(parents=True, exist_ok=True) + (path / "pyproject.toml").write_text(content) + + +def test_internal_editables_follow_only_the_selected_target_surface(tmp_path: Path) -> None: + _write_project( + tmp_path / "packages/target", + """ +[project] +name = "agent-framework-target" +version = "1.0.0" +dependencies = ["agent-framework-core"] + +[project.optional-dependencies] +dev = ["agent-framework-helper"] + +[dependency-groups] +test = ["agent-framework-orchestrations"] +""", + ) + _write_project( + tmp_path / "packages/core", + """ +[project] +name = "agent-framework-core" +version = "1.0.0" +dependencies = [] + +[project.optional-dependencies] +all = ["agent-framework-unrelated"] + +[dependency-groups] +dev = ["agent-framework-group-only"] +""", + ) + _write_project( + tmp_path / "packages/helper", + """ +[project] +name = "agent-framework-helper" +version = "1.0.0" +dependencies = ["agent-framework-core"] +""", + ) + _write_project( + tmp_path / "packages/orchestrations", + """ +[project] +name = "agent-framework-orchestrations" +version = "1.0.0" +dependencies = ["agent-framework-core"] +""", + ) + for package_name in ("unrelated", "group-only"): + _write_project( + tmp_path / f"packages/{package_name}", + f""" +[project] +name = "agent-framework-{package_name}" +version = "1.0.0" +dependencies = [] +""", + ) + + packages = load_workspace_package_configs(tmp_path) + editables = resolve_internal_editables( + "agent-framework-target", + packages, + dependency_groups=["test"], + optional_extras=["dev"], + ) + + assert editables == sorted([ + (tmp_path / "packages/core").resolve(), + (tmp_path / "packages/helper").resolve(), + (tmp_path / "packages/orchestrations").resolve(), + ]) + + +def test_internal_editables_follow_explicitly_requested_transitive_extras(tmp_path: Path) -> None: + _write_project( + tmp_path / "packages/target", + """ +[project] +name = "agent-framework-target" +version = "1.0.0" +dependencies = ["agent-framework-core[all]"] +""", + ) + _write_project( + tmp_path / "packages/core", + """ +[project] +name = "agent-framework-core" +version = "1.0.0" +dependencies = [] + +[project.optional-dependencies] +all = ["agent-framework-connector"] +""", + ) + _write_project( + tmp_path / "packages/connector", + """ +[project] +name = "agent-framework-connector" +version = "1.0.0" +dependencies = ["agent-framework-core"] +""", + ) + + packages = load_workspace_package_configs(tmp_path) + editables = resolve_internal_editables( + "agent-framework-target", + packages, + dependency_groups=[], + optional_extras=[], + ) + + assert editables == sorted([ + (tmp_path / "packages/connector").resolve(), + (tmp_path / "packages/core").resolve(), + ]) + + +@pytest.mark.parametrize("selector", [_select_lower_tasks, _select_upper_tasks]) +def test_dependency_pyright_takes_priority_for_bound_validation( + selector: Callable[[set[str]], list[str]], +) -> None: + assert selector({"test", "pyright", "dependency-pyright"}) == ["dependency-pyright", "test"] + + +def test_test_mode_uses_dependency_pyright_when_available(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + """ +[tool.uv.workspace] +members = ["packages/*"] +""" + ) + _write_project( + tmp_path / "packages/core", + """ +[project] +name = "agent-framework-core" +version = "1.0.0" +dependencies = [] + +[tool.poe.tasks] +test = "pytest" +pyright = "pyright" +dependency-pyright = "pyright --project pyrightconfig.dependency.json" +""", + ) + + plans = _build_test_plans(tmp_path, "core") + + assert len(plans) == 1 + assert plans[0].typing_task == "dependency-pyright" + + +def test_dependency_pyright_reuses_root_test_requirements(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text( + """ +[dependency-groups] +test = ["azure-monitor-opentelemetry", "mcp[ws]"] +""" + ) + command = ["uv", "run"] + + extend_command_with_task(command, "dependency-pyright", workspace_root=tmp_path) + + assert command[:6] == [ + "uv", + "run", + "--with", + "azure-monitor-opentelemetry", + "--with", + "mcp[ws]", + ] + assert command[-3:-1] == ["python", "-c"] diff --git a/python/scripts/dependencies/validate_dependency_bounds.py b/python/scripts/dependencies/validate_dependency_bounds.py index 0f7d26c7052..aa8cac6fe4a 100644 --- a/python/scripts/dependencies/validate_dependency_bounds.py +++ b/python/scripts/dependencies/validate_dependency_bounds.py @@ -26,20 +26,17 @@ from datetime import datetime, timezone from pathlib import Path -import tomli +from packaging.utils import canonicalize_name 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, + load_workspace_package_configs, + resolve_internal_editables, ) -from scripts.dependencies._dependency_bounds_upper_impl import ( - _build_internal_graph, - _build_workspace_package_map, - _load_package_name, - _resolve_internal_editables, -) +from scripts.dependencies._dependency_bounds_upper_impl import _load_package_name from scripts.task_runner import discover_projects, extract_poe_tasks, project_filter_matches _LOWER_IMPL_MODULE = "scripts.dependencies._dependency_bounds_lower_impl" @@ -52,6 +49,7 @@ class PackageTestPlan: project_path: Path package_name: str + typing_task: str dependency_groups: list[str] include_dev_extra: bool optional_extras: list[str] @@ -85,8 +83,7 @@ def _coerce_subprocess_output(output: str | bytes | None) -> str: def _build_test_plans(workspace_root: Path, package_filter: str | None) -> list[PackageTestPlan]: """Build per-package test plans for the requested workspace selector.""" workspace_pyproject = workspace_root / "pyproject.toml" - package_map = _build_workspace_package_map(workspace_root) - internal_graph = _build_internal_graph(workspace_root, package_map) + workspace_packages = load_workspace_package_configs(workspace_root) plans: list[PackageTestPlan] = [] missing_tasks: list[str] = [] @@ -107,25 +104,36 @@ def _build_test_plans(workspace_root: Path, package_filter: str | None) -> list[ continue available_tasks = extract_poe_tasks(pyproject_file) - required_tasks = {"test", "pyright"} + typing_task = "dependency-pyright" if "dependency-pyright" in available_tasks else "pyright" + required_tasks = {"test", typing_task} 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 {} + workspace_package = workspace_packages[str(canonicalize_name(package_name))] + dependency_group_names = sorted(workspace_package.dependency_groups) + include_dev_extra = "dev" in workspace_package.optional_dependencies + optional_extra_names = sorted( + name for name in workspace_package.optional_dependencies if name not in {"all", "dev"} + ) + selected_extra_names = list(optional_extra_names) + if include_dev_extra: + selected_extra_names.append("dev") plans.append( PackageTestPlan( project_path=project_path, package_name=package_name, - dependency_groups=sorted(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), + typing_task=typing_task, + dependency_groups=dependency_group_names, + include_dev_extra=include_dev_extra, + optional_extras=optional_extra_names, + internal_editables=resolve_internal_editables( + package_name, + workspace_packages, + dependency_groups=dependency_group_names, + optional_extras=selected_extra_names, + ), ) ) @@ -151,7 +159,7 @@ def _run_package_tasks( # stay inside uv's isolated throwaway environment instead of mutating `.venv`. env.pop("VIRTUAL_ENV", None) - for task_name in ("test", "pyright"): + for task_name in ("test", plan.typing_task): command = [ "uv", "--no-progress", @@ -174,7 +182,7 @@ def _run_package_tasks( 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) + extend_command_with_task(command, task_name, workspace_root=workspace_root) if dry_run: print(f"[cyan]DRY RUN[/cyan] {' '.join(command)}") diff --git a/python/uv.lock b/python/uv.lock index 7a8effc0636..64bf0b02598 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -214,11 +214,16 @@ dev = [ { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] +[package.dev-dependencies] +test = [ + { name = "agent-framework-orchestrations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + [package.metadata] requires-dist = [ { name = "ag-ui-protocol", specifier = ">=0.1.19,<0.2" }, { name = "agent-framework-core", editable = "packages/core" }, - { name = "fastapi", specifier = ">=0.121.0,<0.138.1" }, + { name = "fastapi", specifier = ">=0.121.0,<0.140.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, { name = "sse-starlette", specifier = ">=3.4.5,<4" }, @@ -226,6 +231,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +test = [{ name = "agent-framework-orchestrations", editable = "packages/orchestrations" }] + [[package]] name = "agent-framework-anthropic" version = "1.0.0b260721"