From 12d92596e0d46533abef550d6be284c072ad8da4 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:14:46 +0800 Subject: [PATCH 1/4] Add the missing py.typed markers, and check for them going forward 18 packages declare `Typing :: Typed` but ship no py.typed, so the promise never reaches anyone who installs them: PEP 561 keeps it through the marker file inside the package directory, and without it a type checker must treat the whole package as untyped. Verified on the published wheel, not just the tree - agent_framework_foundry 1.13.1 carried the classifier and not the file, so `from agent_framework.foundry import FoundryChatClient` resolved to `Any` while `from agent_framework import Agent`, from a package that does ship the marker, resolved to a full signature. flit already ships whatever is in the package directory, so the marker is not being excluded by configuration - it was simply absent. Adding the file is the whole change. The package management skill now checks the two against each other, since a new package can repeat the same mismatch. --- .../skills/python-package-management/SKILL.md | 19 +++++++++++++++++++ .../packages/a2a/agent_framework_a2a/py.typed | 0 .../agent_framework_anthropic/py.typed | 0 .../py.typed | 0 .../py.typed | 0 .../bedrock/agent_framework_bedrock/py.typed | 0 .../claude/agent_framework_claude/py.typed | 0 .../agent_framework_copilotstudio/py.typed | 0 .../agent_framework_declarative/py.typed | 0 .../devui/agent_framework_devui/py.typed | 0 .../foundry/agent_framework_foundry/py.typed | 0 .../agent_framework_foundry_hosting/py.typed | 0 .../agent_framework_github_copilot/py.typed | 0 .../py.typed | 0 .../agent_framework_hosting_telegram/py.typed | 0 .../hosting/agent_framework_hosting/py.typed | 0 .../mem0/agent_framework_mem0/py.typed | 0 .../purview/agent_framework_purview/py.typed | 0 .../redis/agent_framework_redis/py.typed | 0 19 files changed, 19 insertions(+) create mode 100644 python/packages/a2a/agent_framework_a2a/py.typed create mode 100644 python/packages/anthropic/agent_framework_anthropic/py.typed create mode 100644 python/packages/azure-contentunderstanding/agent_framework_azure_contentunderstanding/py.typed create mode 100644 python/packages/azure-cosmos-memory/agent_framework_azure_cosmos_memory/py.typed create mode 100644 python/packages/bedrock/agent_framework_bedrock/py.typed create mode 100644 python/packages/claude/agent_framework_claude/py.typed create mode 100644 python/packages/copilotstudio/agent_framework_copilotstudio/py.typed create mode 100644 python/packages/declarative/agent_framework_declarative/py.typed create mode 100644 python/packages/devui/agent_framework_devui/py.typed create mode 100644 python/packages/foundry/agent_framework_foundry/py.typed create mode 100644 python/packages/foundry_hosting/agent_framework_foundry_hosting/py.typed create mode 100644 python/packages/github_copilot/agent_framework_github_copilot/py.typed create mode 100644 python/packages/hosting-responses/agent_framework_hosting_responses/py.typed create mode 100644 python/packages/hosting-telegram/agent_framework_hosting_telegram/py.typed create mode 100644 python/packages/hosting/agent_framework_hosting/py.typed create mode 100644 python/packages/mem0/agent_framework_mem0/py.typed create mode 100644 python/packages/purview/agent_framework_purview/py.typed create mode 100644 python/packages/redis/agent_framework_redis/py.typed diff --git a/python/.github/skills/python-package-management/SKILL.md b/python/.github/skills/python-package-management/SKILL.md index 549eb9fbb1e..3be4a1725ac 100644 --- a/python/.github/skills/python-package-management/SKILL.md +++ b/python/.github/skills/python-package-management/SKILL.md @@ -156,6 +156,25 @@ 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, so a package that + declares `Typing :: Typed` without shipping that file resolves to `Any` for every type checker + that sees the installed distribution. 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/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/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/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/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/py.typed b/python/packages/redis/agent_framework_redis/py.typed new file mode 100644 index 00000000000..e69de29bb2d From e9624f930c5292892c6b4d2c46deb8dc84b92509 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:08:34 +0800 Subject: [PATCH 2/4] Soften the type-checker claim in the package management skill The skill said a package that declares `Typing :: Typed` without shipping `py.typed` "resolves to Any for every type checker", but PEP 561 only makes the marker the signal; what a checker then does with an untyped import is its own business. State the portable guarantee instead: without the marker the package is not treated as typed, so its API falls back to the checker's untyped-import behaviour rather than the inline annotations. --- python/.github/skills/python-package-management/SKILL.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/.github/skills/python-package-management/SKILL.md b/python/.github/skills/python-package-management/SKILL.md index 3be4a1725ac..41325f72e60 100644 --- a/python/.github/skills/python-package-management/SKILL.md +++ b/python/.github/skills/python-package-management/SKILL.md @@ -157,9 +157,11 @@ Every new package starts as `alpha`. 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, so a package that - declares `Typing :: Typed` without shipping that file resolves to `Any` for every type checker - that sees the installed distribution. Check both, not one: + 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 From 65a74a1a07c05127a65ae89c7c10111fbdd8aee2 Mon Sep 17 00:00:00 2001 From: opencode <204683769+feiiiiii5@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:28:24 +0800 Subject: [PATCH 3/4] Fix the test typing errors the py.typed markers exposed Adding the markers makes mypy check these packages' tests instead of skipping them, which surfaced 18 errors across five packages. All of them are in test files, and each is fixed the way the surrounding code already does it: - module and method monkeypatching moves to monkeypatch.setattr, the idiom used elsewhere in these tests, so mypy no longer sees an assignment to a type or a method. The assertions now target the mock directly instead of reading it back off the patched object. - bedrock imports BedrockChatClient and BedrockSettings from the package instead of the private _chat_client module; both are in __all__ and the same test file already imported BedrockChatClient publicly. - _redis_result gains an overload pair. Awaitable[_T] | _T left mypy unable to solve the TypeVar from a coroutine argument; the overloads let it infer, so the test needs no cast. - the two default_options arguments and four async-for loops get mypy's error code added to the per-checker suppressions already on those lines. Verified with the repo's own five gating checkers (mypy, zuban, pyrefly, ty, pyright) on Python 3.11 for all five packages, and the affected test suites pass: 1927 passed, 454 skipped. --- .../anthropic/tests/test_anthropic_client.py | 10 +++---- .../bedrock/tests/test_bedrock_client.py | 8 ++++-- .../bedrock/tests/test_bedrock_settings.py | 2 +- .../declarative/tests/test_graph_executors.py | 26 +++++++------------ .../tests/test_responses_int.py | 2 +- .../foundry_hosting/tests/test_state_store.py | 8 +++--- .../foundry_hosting/tests/test_toolbox.py | 16 +++++++----- .../_history_provider.py | 10 ++++++- 8 files changed, 44 insertions(+), 38 deletions(-) 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/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/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/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/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index b29dc672f7d..21ad74afcc5 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -12,7 +12,7 @@ import warnings from collections.abc import Awaitable, Sequence from inspect import isawaitable -from typing import Any, ClassVar, Literal, TypeVar, cast +from typing import Any, ClassVar, Literal, TypeVar, cast, overload import redis.asyncio as redis from agent_framework import Message @@ -26,6 +26,14 @@ _T = TypeVar("_T") +@overload +async def _redis_result(value: Awaitable[_T]) -> _T: ... + + +@overload +async def _redis_result(value: _T) -> _T: ... + + async def _redis_result(value: Awaitable[_T] | _T) -> _T: """Await a redis-py command result that is annotated as the sync/async union. From 12321a8f4836b957b5f74461bfbc5bf8c905a038 Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:03:18 +0800 Subject: [PATCH 4/4] fix(redis): drop the overloads that made the union unresolvable pyright reported one error here: `current_count: int` at line 300, where `llen` is annotated `int | Awaitable[int]`. The overload pair added to silence the union made it worse. For a union argument the `Awaitable[_T]` arm matches and nothing consumes the other half, so `_T` binds to the whole union and the awaited value types as `Awaitable[int] | int`. Listing the union arm first does not fix it -- that arm then shadows the bare-awaitable one. A single `Awaitable[_T] | _T` parameter returning `_T` resolves to the awaited type for all three call sites, verified against the annotations redis-py actually ships (`llen` `int | Awaitable[int]`, `rpush` `int | Awaitable[int]`, `ltrim` `bool | Awaitable[bool]`). --- .../_history_provider.py | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index 21ad74afcc5..892cac1af17 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -12,7 +12,7 @@ import warnings from collections.abc import Awaitable, Sequence from inspect import isawaitable -from typing import Any, ClassVar, Literal, TypeVar, cast, overload +from typing import Any, ClassVar, Literal, TypeVar, cast import redis.asyncio as redis from agent_framework import Message @@ -26,23 +26,24 @@ _T = TypeVar("_T") -@overload -async def _redis_result(value: Awaitable[_T]) -> _T: ... - - -@overload -async def _redis_result(value: _T) -> _T: ... - - 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)