Skip to content

Commit e25ba7b

Browse files
feat(client): expose close() and async context manager support on abstract Client (#719)
# Description Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - Important Prefixes for [release-please](https://github.com/googleapis/release-please): - `fix:` which represents bug fixes, and correlates to a [SemVer](https://semver.org/) patch. - `feat:` represents a new feature, and correlates to a SemVer minor. - `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking change (indicated by the `!`) and will result in a SemVer major. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [x] Appropriate docs were updated (if necessary) Fixes #689 🦕 ### Problem The abstract `Client` class (`src/a2a/client/client.py`) — the public interface returned by `ClientFactory.connect()` — does not declare `close()`, `__aenter__`, or `__aexit__`. Code typed against `Client` cannot use the async context manager pattern or call `close()`: ```python client = await ClientFactory.connect(card) # client is typed as Client, not BaseClient async with client: # TypeError ... await client.close() # AttributeError ``` `ClientTransport` already supports this protocol since #682, and `BaseClient` since #688, but the gap at the abstract interface level means the pattern is unavailable to consumers that depend on the `Client` type. The integration tests reflect this — they rely on `hasattr` checks instead of the type system: ```python if hasattr(transport, 'close'): await transport.close() ``` ### Fix - Add `close()` as an abstract method on `Client`, consistent with the existing 7 abstract methods. - Add `__aenter__` and `__aexit__` as concrete methods on `Client`, delegating to `close()`. `__aenter__` returns `Self` (via `typing_extensions`), matching the convention established in `ClientTransport` (#682). `BaseClient` already implements `close()` (delegating to `self._transport.close()`), so it satisfies the new abstract method with no additional changes. This enables the idiomatic pattern at the `Client` abstraction level: ```python async with await ClientFactory.connect(card) as client: async for event in client.send_message(msg): ... # close() called automatically, even on exceptions ``` ### Tests No new tests needed. The existing `test_base_client_async_context_manager` and `test_base_client_async_context_manager_on_exception` in `test_base_client.py` already exercise the `__aenter__`/`__aexit__`/`close()` chain through `BaseClient`, which inherits these methods from `Client`. Happy to add dedicated tests if maintainers prefer a different approach. --------- Co-authored-by: Ivan Shymko <ishymko@google.com>
1 parent 7dec763 commit e25ba7b

2 files changed

Lines changed: 20 additions & 16 deletions

File tree

src/a2a/client/base_client.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
from collections.abc import AsyncGenerator, AsyncIterator, Callable
2-
from types import TracebackType
32
from typing import Any
43

5-
from typing_extensions import Self
6-
74
from a2a.client.client import (
85
Client,
96
ClientCallContext,
@@ -51,19 +48,6 @@ def __init__(
5148
self._config = config
5249
self._transport = transport
5350

54-
async def __aenter__(self) -> Self:
55-
"""Enters the async context manager, returning the client itself."""
56-
return self
57-
58-
async def __aexit__(
59-
self,
60-
exc_type: type[BaseException] | None,
61-
exc_val: BaseException | None,
62-
exc_tb: TracebackType | None,
63-
) -> None:
64-
"""Exits the async context manager, ensuring close() is called."""
65-
await self.close()
66-
6751
async def send_message(
6852
self,
6953
request: Message,

src/a2a/client/client.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33

44
from abc import ABC, abstractmethod
55
from collections.abc import AsyncIterator, Callable, Coroutine
6+
from types import TracebackType
67
from typing import Any
78

89
import httpx
910

11+
from typing_extensions import Self
12+
1013
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
1114
from a2a.client.optionals import Channel
1215
from a2a.types.a2a_pb2 import (
@@ -110,6 +113,19 @@ def __init__(
110113
self._consumers = consumers
111114
self._middleware = middleware
112115

116+
async def __aenter__(self) -> Self:
117+
"""Enters the async context manager."""
118+
return self
119+
120+
async def __aexit__(
121+
self,
122+
exc_type: type[BaseException] | None,
123+
exc_val: BaseException | None,
124+
exc_tb: TracebackType | None,
125+
) -> None:
126+
"""Exits the async context manager and closes the client."""
127+
await self.close()
128+
113129
@abstractmethod
114130
async def send_message(
115131
self,
@@ -242,3 +258,7 @@ async def consume(
242258
return
243259
for c in self._consumers:
244260
await c(event, card)
261+
262+
@abstractmethod
263+
async def close(self) -> None:
264+
"""Closes the client and releases any underlying resources."""

0 commit comments

Comments
 (0)