diff --git a/python/.github/skills/python-package-management/SKILL.md b/python/.github/skills/python-package-management/SKILL.md index 549eb9fbb1e..41325f72e60 100644 --- a/python/.github/skills/python-package-management/SKILL.md +++ b/python/.github/skills/python-package-management/SKILL.md @@ -156,6 +156,27 @@ Every new package starts as `alpha`. 8. Add the package to `python/PACKAGE_STATUS.md` and keep that file updated when packages are added, removed, renamed, or promoted. If the package exposes individually staged APIs, keep the feature list there current too. +9. Keep the `Typing :: Typed` classifier and the `py.typed` marker in step. The classifier alone is a + promise PEP 561 keeps through the `py.typed` file inside the package directory, and that file is + the signal checkers act on: a package that declares `Typing :: Typed` without shipping it is not + treated as typed, so its API falls back to whatever a checker does with an untyped import + (`Any`, `Unknown`, or a missing-stub diagnostic) instead of the inline annotations. Check both, + not one: + + ```bash + # every package that claims to be typed must ship the marker + cd python/packages + for d in */; do + d=${d%/} + [ -f "$d/pyproject.toml" ] || continue + if grep -q "Typing :: Typed" "$d/pyproject.toml" && ! ls "$d"/agent_framework*/py.typed >/dev/null 2>&1; then + echo "missing py.typed: $d" + fi + done + ``` + + Note that `py.typed` only makes annotations that are already there visible; it adds none, so a + package that has not been type-checked may surface further errors once the marker lands. Recommended dependency workflow during connector implementation: diff --git a/python/packages/a2a/agent_framework_a2a/py.typed b/python/packages/a2a/agent_framework_a2a/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/anthropic/agent_framework_anthropic/py.typed b/python/packages/anthropic/agent_framework_anthropic/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index fb35902fe74..0b330a469cb 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -1286,7 +1286,7 @@ async def create(**kwargs: Any) -> BetaMessage: agent = Agent( client=AnthropicClient(anthropic_client=transport, model="claude-3-5-sonnet-20241022"), - default_options=cast( + default_options=cast( # type: ignore[arg-type] AnthropicChatOptions, {"model": "claude-3-5-sonnet-20241022", "max_tokens": 64, "instructions": system_blocks}, ), @@ -1961,7 +1961,7 @@ async def mock_stream(): chat_options = ChatOptions(max_tokens=10) chunks: list[ChatResponseUpdate] = [] - async for chunk in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable] + async for chunk in client._inner_get_response( # type: ignore[attr-defined, union-attr] # ty: ignore[not-iterable] messages=messages, options=chat_options, stream=True ): if chunk: @@ -1987,7 +1987,7 @@ async def mock_stream(): messages = [Message(role="user", contents=["Hi"])] options: dict[str, Any] = {"max_tokens": 10, "stream": False} - async for _ in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable] + async for _ in client._inner_get_response( # type: ignore[attr-defined, union-attr] # ty: ignore[not-iterable] messages=messages, options=options, stream=True, @@ -2051,7 +2051,7 @@ async def test_inner_get_response_streaming_wraps_sdk_errors(mock_anthropic_clie anthropic_sdk.AuthenticationError, 401, "invalid api key" ) with pytest.raises(ChatClientInvalidAuthException, match="Anthropic"): - async for _ in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable] + async for _ in client._inner_get_response( # type: ignore[attr-defined, union-attr] # ty: ignore[not-iterable] messages=messages, options=chat_options, stream=True ): pass @@ -2066,7 +2066,7 @@ async def _raise_after_first_event() -> Any: mock_anthropic_client.beta.messages.create.side_effect = None mock_anthropic_client.beta.messages.create.return_value = _raise_after_first_event() with pytest.raises(ChatClientInvalidAuthException, match="Anthropic"): - async for _ in client._inner_get_response( # type: ignore[attr-defined] # ty: ignore[not-iterable] + async for _ in client._inner_get_response( # type: ignore[attr-defined, union-attr] # ty: ignore[not-iterable] messages=messages, options=chat_options, stream=True ): pass diff --git a/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/py.typed b/python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/azure-cosmos-memory/agent_framework_azure_cosmos_memory/py.typed b/python/packages/azure-cosmos-memory/agent_framework_azure_cosmos_memory/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/bedrock/agent_framework_bedrock/py.typed b/python/packages/bedrock/agent_framework_bedrock/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/bedrock/tests/test_bedrock_client.py b/python/packages/bedrock/tests/test_bedrock_client.py index 5e25cad9692..7a77cf718d8 100644 --- a/python/packages/bedrock/tests/test_bedrock_client.py +++ b/python/packages/bedrock/tests/test_bedrock_client.py @@ -15,8 +15,12 @@ from boto3.session import Session as Boto3Session from botocore.client import BaseClient -from agent_framework_bedrock import BedrockChatClient, BedrockChatOptions, BedrockEmbeddingClient -from agent_framework_bedrock._chat_client import BedrockSettings +from agent_framework_bedrock import ( + BedrockChatClient, + BedrockChatOptions, + BedrockEmbeddingClient, + BedrockSettings, +) from agent_framework_bedrock._feature_usage import FeatureIndex diff --git a/python/packages/bedrock/tests/test_bedrock_settings.py b/python/packages/bedrock/tests/test_bedrock_settings.py index a0cc3d1fb70..185b18dd04c 100644 --- a/python/packages/bedrock/tests/test_bedrock_settings.py +++ b/python/packages/bedrock/tests/test_bedrock_settings.py @@ -14,7 +14,7 @@ from agent_framework._settings import load_settings from pydantic import BaseModel -from agent_framework_bedrock._chat_client import BedrockChatClient, BedrockSettings +from agent_framework_bedrock import BedrockChatClient, BedrockSettings class _WeatherArgs(BaseModel): diff --git a/python/packages/claude/agent_framework_claude/py.typed b/python/packages/claude/agent_framework_claude/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/py.typed b/python/packages/copilotstudio/agent_framework_copilotstudio/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/declarative/agent_framework_declarative/py.typed b/python/packages/declarative/agent_framework_declarative/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/declarative/tests/test_graph_executors.py b/python/packages/declarative/tests/test_graph_executors.py index d01ab1c744c..9d54981c4ec 100644 --- a/python/packages/declarative/tests/test_graph_executors.py +++ b/python/packages/declarative/tests/test_graph_executors.py @@ -1771,7 +1771,7 @@ def test_import_guard_exists(self): engine = base_mod.Engine assert engine is None or callable(engine) - def test_eval_raises_when_engine_unavailable(self): + def test_eval_raises_when_engine_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None: """eval() should raise RuntimeError when Engine is None.""" import agent_framework_declarative._workflows._declarative_base as base_mod @@ -1784,15 +1784,11 @@ def test_eval_raises_when_engine_unavailable(self): state = DeclarativeWorkflowState(mock_state) state.initialize({"name": "test"}) - original_engine = base_mod.Engine - try: - base_mod.Engine = cast(Any, None) - with pytest.raises(RuntimeError, match="PowerFx is not available"): - state.eval("=Local.counter + 1") - finally: - base_mod.Engine = original_engine + monkeypatch.setattr(base_mod, "Engine", None) + with pytest.raises(RuntimeError, match="PowerFx is not available"): + state.eval("=Local.counter + 1") - def test_eval_passes_through_plain_strings_without_engine(self): + def test_eval_passes_through_plain_strings_without_engine(self, monkeypatch: pytest.MonkeyPatch) -> None: """Non-PowerFx strings (no leading '=') should work without Engine.""" import agent_framework_declarative._workflows._declarative_base as base_mod @@ -1805,14 +1801,10 @@ def test_eval_passes_through_plain_strings_without_engine(self): state = DeclarativeWorkflowState(mock_state) state.initialize() - original_engine = base_mod.Engine - try: - base_mod.Engine = cast(Any, None) - assert state.eval("hello world") == "hello world" - assert state.eval("") == "" - assert state.eval(cast("str", 42)) == 42 - finally: - base_mod.Engine = original_engine + monkeypatch.setattr(base_mod, "Engine", None) + assert state.eval("hello world") == "hello world" + assert state.eval("") == "" + assert state.eval(cast("str", 42)) == 42 class TestExecutorKwargsForwarding: diff --git a/python/packages/devui/agent_framework_devui/py.typed b/python/packages/devui/agent_framework_devui/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/foundry/agent_framework_foundry/py.typed b/python/packages/foundry/agent_framework_foundry/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/py.typed b/python/packages/foundry_hosting/agent_framework_foundry_hosting/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/foundry_hosting/tests/test_responses_int.py b/python/packages/foundry_hosting/tests/test_responses_int.py index 2648e220147..b5dac60c834 100644 --- a/python/packages/foundry_hosting/tests/test_responses_int.py +++ b/python/packages/foundry_hosting/tests/test_responses_int.py @@ -760,7 +760,7 @@ async def foundry_responses_boundary(request: Any) -> Any: "Keep the final answer to one short sentence." ), tools=[learn_mcp], - default_options={ # pyrefly: ignore[bad-argument-type] + default_options={ # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] "store": False, "reasoning": {"effort": "low", "summary": "auto"}, "include": ["reasoning.encrypted_content"], diff --git a/python/packages/foundry_hosting/tests/test_state_store.py b/python/packages/foundry_hosting/tests/test_state_store.py index d045560bafc..6ed3b84fdce 100644 --- a/python/packages/foundry_hosting/tests/test_state_store.py +++ b/python/packages/foundry_hosting/tests/test_state_store.py @@ -291,19 +291,19 @@ async def test_delete_reports_whether_checkpoint_existed(deleted_id: str | None, store.delete_item.assert_awaited_once_with("checkpoint-1", call_id="call-1") -async def test_get_latest_uses_timestamp_and_list_ids_filters() -> None: +async def test_get_latest_uses_timestamp_and_list_ids_filters(monkeypatch: pytest.MonkeyPatch) -> None: storage = FoundryCheckpointStore("context-1", _platform_context()) older = _checkpoint("older", timestamp="2026-01-01T00:00:00+00:00") newer = _checkpoint("newer", timestamp="2026-01-02T00:00:00+00:00") - storage.list_checkpoints = AsyncMock(return_value=[newer, older]) # zuban:ignore + monkeypatch.setattr(storage, "list_checkpoints", AsyncMock(return_value=[newer, older])) assert await storage.get_latest(workflow_name="workflow") == newer assert await storage.list_checkpoint_ids(workflow_name="workflow") == ["newer", "older"] -async def test_get_latest_returns_none_when_no_checkpoints_exist() -> None: +async def test_get_latest_returns_none_when_no_checkpoints_exist(monkeypatch: pytest.MonkeyPatch) -> None: storage = FoundryCheckpointStore("context-1", _platform_context()) - storage.list_checkpoints = AsyncMock(return_value=[]) # zuban:ignore + monkeypatch.setattr(storage, "list_checkpoints", AsyncMock(return_value=[])) assert await storage.get_latest(workflow_name="workflow") is None diff --git a/python/packages/foundry_hosting/tests/test_toolbox.py b/python/packages/foundry_hosting/tests/test_toolbox.py index bfcf363609e..bd35992e397 100644 --- a/python/packages/foundry_hosting/tests/test_toolbox.py +++ b/python/packages/foundry_hosting/tests/test_toolbox.py @@ -285,21 +285,22 @@ def test_sync_auth_flow_removes_call_id_from_reused_request_when_context_absent( assert "x-agent-foundry-call-id" not in prepared.headers -async def test_close_closes_owned_http_client() -> None: +async def test_close_closes_owned_http_client(monkeypatch: pytest.MonkeyPatch) -> None: toolbox = FoundryToolbox( _FakeCredential(), # type: ignore url="https://h/toolboxes/tb/mcp", ) client = toolbox._httpx_client assert client is not None - client.aclose = AsyncMock() # zuban: ignore + aclose = AsyncMock() + monkeypatch.setattr(client, "aclose", aclose) await toolbox.close() - client.aclose.assert_awaited_once() + aclose.assert_awaited_once() # Idempotent: a second close does not re-close the client. await toolbox.close() - client.aclose.assert_awaited_once() + aclose.assert_awaited_once() def test_as_skills_provider_returns_provider() -> None: @@ -540,7 +541,7 @@ def test_as_skills_provider_forwards_only_set_archive_options() -> None: class TestFoundryToolboxReconnection: - async def test_close_preserves_credential_for_reconnection(self) -> None: + async def test_close_preserves_credential_for_reconnection(self, monkeypatch: pytest.MonkeyPatch) -> None: """After close(), get_mcp_client() should recreate an authenticated client.""" cred = _FakeCredential("reconnect-token") toolbox = FoundryToolbox( @@ -558,10 +559,11 @@ async def test_close_preserves_credential_for_reconnection(self) -> None: original_auth = toolbox._httpx_client.auth client = toolbox._httpx_client - client.aclose = AsyncMock() # zuban: ignore + aclose = AsyncMock() + monkeypatch.setattr(client, "aclose", aclose) await toolbox.close() - client.aclose.assert_awaited_once() + aclose.assert_awaited_once() assert toolbox._httpx_client is None assert toolbox._credential is cred diff --git a/python/packages/github_copilot/agent_framework_github_copilot/py.typed b/python/packages/github_copilot/agent_framework_github_copilot/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/py.typed b/python/packages/hosting-responses/agent_framework_hosting_responses/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting-telegram/agent_framework_hosting_telegram/py.typed b/python/packages/hosting-telegram/agent_framework_hosting_telegram/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/hosting/agent_framework_hosting/py.typed b/python/packages/hosting/agent_framework_hosting/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/mem0/agent_framework_mem0/py.typed b/python/packages/mem0/agent_framework_mem0/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/purview/agent_framework_purview/py.typed b/python/packages/purview/agent_framework_purview/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index b29dc672f7d..892cac1af17 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -30,11 +30,20 @@ async def _redis_result(value: Awaitable[_T] | _T) -> _T: """Await a redis-py command result that is annotated as the sync/async union. Several redis-py commands are annotated as returning ``Awaitable[T] | T`` even on the asyncio - client, so awaiting them directly does not type-check. Newer redis-py releases narrow those - annotations to the awaitable alone, which makes a bare ``# type: ignore`` *required* on the - older annotations and *unnecessary* on the newer ones: no single ignore comment satisfies the - whole supported range. Normalising through this helper type-checks on every supported version - without an ignore comment. + client -- ``llen`` as ``int | Awaitable[int]``, ``ltrim`` as ``bool | Awaitable[bool]`` -- so + awaiting them directly does not type-check. Normalising through this helper does. + + This takes one signature rather than overloads on purpose. An overload pair of + ``Awaitable[_T]`` and ``_T`` makes a checker bind ``_T`` to the whole union for an + ``Awaitable[T] | T`` argument: the ``Awaitable[_T]`` arm matches, and nothing consumes the + other one. The awaited value then types as the union, so a ``int`` annotation on the result is + rejected even though the runtime value is an ``int``. Listing the union arm first does not help + -- it then shadows the bare-awaitable arm. The single union parameter resolves to the awaited + type, which is what every call site here wants. + + The one shape it does not cover is a parameter annotated as a bare ``Awaitable[T]`` with no + ``| T``. redis-py does not use that on the asyncio client as of 8.1, so no call site needs it; + if one appears, widen this signature rather than reaching for an overload pair. """ if isawaitable(value): return cast("_T", await value) diff --git a/python/packages/redis/agent_framework_redis/py.typed b/python/packages/redis/agent_framework_redis/py.typed new file mode 100644 index 00000000000..e69de29bb2d