Skip to content

Commit ae9dc88

Browse files
feat: add async context manager support to BaseClient (#688)
# 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) Follow-up to #682, as suggested by @ishymko in the [review](#682 (review)). This extends the async context manager pattern to `BaseClient`, which wraps `ClientTransport` and also exposes a `close()` method. Fixes #674 🦕 ## Problem `BaseClient` delegates resource cleanup to its underlying `ClientTransport` via `close()`, but doesn't implement `__aenter__`/`__aexit__`. This means clients cannot be used with `async with`, leading to the same resource leak risk that #682 solved for transports: ```python client = BaseClient(card=card, config=config, transport=transport, consumers=[], middleware=[]) result = await client.send_message(msg) # if this raises, close() is never called await client.close() ``` ## Fix Added `__aenter__` and `__aexit__` methods to `BaseClient` in `src/a2a/client/base_client.py`: `__aenter__` returns `self` `__aexit__ `awaits `close()` This enables the standard async context manager pattern: ```python async with BaseClient(card=card, config=config, transport=transport, consumers=[], middleware=[]) as client: async for event in client.send_message(msg): ... # close() called automatically, even on exceptions ``` This is a non-breaking, additive change. Calling `close()` manually or via `try/finally` continues to work exactly as before. ## Test Tests were added to `tests/client/test_base_client.py`, following the same approach as the `ClientTransport` tests from #682. Release-As: 0.3.23
1 parent d3c973f commit ae9dc88

2 files changed

Lines changed: 36 additions & 0 deletions

File tree

src/a2a/client/base_client.py

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

5+
from typing_extensions import Self
6+
47
from a2a.client.client import (
58
Client,
69
ClientCallContext,
@@ -43,6 +46,19 @@ def __init__(
4346
self._config = config
4447
self._transport = transport
4548

49+
async def __aenter__(self) -> Self:
50+
"""Enters the async context manager, returning the client itself."""
51+
return self
52+
53+
async def __aexit__(
54+
self,
55+
exc_type: type[BaseException] | None,
56+
exc_val: BaseException | None,
57+
exc_tb: TracebackType | None,
58+
) -> None:
59+
"""Exits the async context manager, ensuring close() is called."""
60+
await self.close()
61+
4662
async def send_message(
4763
self,
4864
request: Message,

tests/client/test_base_client.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,26 @@ async def test_transport_async_context_manager_on_exception() -> None:
8787
transport.close.assert_awaited_once()
8888

8989

90+
@pytest.mark.asyncio
91+
async def test_base_client_async_context_manager(
92+
base_client: BaseClient, mock_transport: AsyncMock
93+
) -> None:
94+
async with base_client as client:
95+
assert client is base_client
96+
mock_transport.close.assert_not_awaited()
97+
mock_transport.close.assert_awaited_once()
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_base_client_async_context_manager_on_exception(
102+
base_client: BaseClient, mock_transport: AsyncMock
103+
) -> None:
104+
with pytest.raises(RuntimeError, match='boom'):
105+
async with base_client:
106+
raise RuntimeError('boom')
107+
mock_transport.close.assert_awaited_once()
108+
109+
90110
@pytest.mark.asyncio
91111
async def test_send_message_streaming(
92112
base_client: BaseClient, mock_transport: MagicMock, sample_message: Message

0 commit comments

Comments
 (0)