Skip to content

Commit 0063146

Browse files
Merge branch 'main' into feat/add-async-context-manager-base-client-674
2 parents 4691753 + d3c973f commit 0063146

10 files changed

Lines changed: 212 additions & 15 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@
3434

3535
---
3636

37+
## 🧩 Compatibility
38+
39+
This SDK implements the A2A Protocol Specification [`v0.3.0`](https://a2a-protocol.org/v0.3.0/specification).
40+
41+
| Transport | Client | Server |
42+
| :--- | :---: | :---: |
43+
| **JSON-RPC** |||
44+
| **HTTP+JSON/REST** |||
45+
| **GRPC** |||
46+
47+
---
48+
3749
## 🚀 Getting Started
3850

3951
### Prerequisites

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ dev = [
105105
"no_implicit_optional",
106106
"trio",
107107
"uvicorn>=0.35.0",
108+
"pytest-timeout>=2.4.0",
108109
"a2a-sdk[all]",
109110
]
110111

src/a2a/client/errors.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ def __init__(self, status_code: int, message: str):
2121
self.message = message
2222
super().__init__(f'HTTP Error {status_code}: {message}')
2323

24+
def __repr__(self) -> str:
25+
"""Returns an unambiguous representation showing structured attributes."""
26+
return (
27+
f'{self.__class__.__name__}('
28+
f'status_code={self.status_code!r}, '
29+
f'message={self.message!r})'
30+
)
31+
2432

2533
class A2AClientJSONError(A2AClientError):
2634
"""Client exception for JSON errors during response parsing or validation."""
@@ -34,6 +42,10 @@ def __init__(self, message: str):
3442
self.message = message
3543
super().__init__(f'JSON Error: {message}')
3644

45+
def __repr__(self) -> str:
46+
"""Returns an unambiguous representation showing structured attributes."""
47+
return f'{self.__class__.__name__}(message={self.message!r})'
48+
3749

3850
class A2AClientTimeoutError(A2AClientError):
3951
"""Client exception for timeout errors during a request."""
@@ -47,6 +59,10 @@ def __init__(self, message: str):
4759
self.message = message
4860
super().__init__(f'Timeout Error: {message}')
4961

62+
def __repr__(self) -> str:
63+
"""Returns an unambiguous representation showing structured attributes."""
64+
return f'{self.__class__.__name__}(message={self.message!r})'
65+
5066

5167
class A2AClientInvalidArgsError(A2AClientError):
5268
"""Client exception for invalid arguments passed to a method."""
@@ -60,6 +76,10 @@ def __init__(self, message: str):
6076
self.message = message
6177
super().__init__(f'Invalid arguments error: {message}')
6278

79+
def __repr__(self) -> str:
80+
"""Returns an unambiguous representation showing structured attributes."""
81+
return f'{self.__class__.__name__}(message={self.message!r})'
82+
6383

6484
class A2AClientInvalidStateError(A2AClientError):
6585
"""Client exception for an invalid client state."""
@@ -73,6 +93,10 @@ def __init__(self, message: str):
7393
self.message = message
7494
super().__init__(f'Invalid state error: {message}')
7595

96+
def __repr__(self) -> str:
97+
"""Returns an unambiguous representation showing structured attributes."""
98+
return f'{self.__class__.__name__}(message={self.message!r})'
99+
76100

77101
class A2AClientJSONRPCError(A2AClientError):
78102
"""Client exception for JSON-RPC errors returned by the server."""
@@ -85,3 +109,7 @@ def __init__(self, error: JSONRPCErrorResponse):
85109
"""
86110
self.error = error.error
87111
super().__init__(f'JSON-RPC Error {error.error}')
112+
113+
def __repr__(self) -> str:
114+
"""Returns an unambiguous representation showing the JSON-RPC error object."""
115+
return f'{self.__class__.__name__}({self.error!r})'

src/a2a/client/transports/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from abc import ABC, abstractmethod
22
from collections.abc import AsyncGenerator, Callable
3+
from types import TracebackType
4+
5+
from typing_extensions import Self
36

47
from a2a.client.middleware import ClientCallContext
58
from a2a.types import (
@@ -19,6 +22,19 @@
1922
class ClientTransport(ABC):
2023
"""Abstract base class for a client transport."""
2124

25+
async def __aenter__(self) -> Self:
26+
"""Enters the async context manager, returning the transport itself."""
27+
return self
28+
29+
async def __aexit__(
30+
self,
31+
exc_type: type[BaseException] | None,
32+
exc_val: BaseException | None,
33+
exc_tb: TracebackType | None,
34+
) -> None:
35+
"""Exits the async context manager, ensuring close() is called."""
36+
await self.close()
37+
2238
@abstractmethod
2339
async def send_message(
2440
self,

src/a2a/server/request_handlers/default_request_handler.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ async def push_notification_callback() -> None:
330330

331331
except Exception:
332332
logger.exception('Agent execution failed')
333+
producer_task.cancel()
333334
raise
334335
finally:
335336
if interrupted_or_non_blocking:
@@ -435,7 +436,12 @@ async def _cleanup_producer(
435436
task_id: str,
436437
) -> None:
437438
"""Cleans up the agent execution task and queue manager entry."""
438-
await producer_task
439+
try:
440+
await producer_task
441+
except asyncio.CancelledError:
442+
logger.debug(
443+
'Producer task %s was cancelled during cleanup', task_id
444+
)
439445
await self._queue_manager.close(task_id)
440446
async with self._running_agents_lock:
441447
self._running_agents.pop(task_id, None)

src/a2a/server/request_handlers/grpc_handler.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,12 @@
33
import logging
44

55
from abc import ABC, abstractmethod
6-
from collections.abc import AsyncIterable, Awaitable, Sequence
7-
from typing import TYPE_CHECKING
6+
from collections.abc import AsyncIterable, Awaitable
87

98

109
try:
1110
import grpc
1211
import grpc.aio
13-
14-
if TYPE_CHECKING:
15-
from grpc.aio._typing import MetadataType
16-
from grpc.aio import Metadata
1712
except ImportError as e:
1813
raise ImportError(
1914
'GrpcHandler requires grpcio and grpcio-tools to be installed. '
@@ -56,14 +51,16 @@ def build(self, context: grpc.aio.ServicerContext) -> ServerCallContext:
5651
def _get_metadata_value(
5752
context: grpc.aio.ServicerContext, key: str
5853
) -> list[str]:
59-
md: MetadataType | None = context.invocation_metadata()
60-
raw_values: list[str | bytes] = []
54+
md = context.invocation_metadata()
55+
if md is None:
56+
return []
57+
6158
lower_key = key.lower()
62-
if isinstance(md, Metadata):
63-
raw_values = md.get_all(lower_key)
64-
elif isinstance(md, Sequence):
65-
raw_values = [e for (k, e) in md if k.lower() == lower_key]
66-
return [e if isinstance(e, str) else e.decode('utf-8') for e in raw_values]
59+
return [
60+
e if isinstance(e, str) else e.decode('utf-8')
61+
for k, e in md
62+
if k.lower() == lower_key
63+
]
6764

6865

6966
class DefaultCallContextBuilder(CallContextBuilder):

tests/client/test_errors.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
import pytest
44

55
from a2a.client import A2AClientError, A2AClientHTTPError, A2AClientJSONError
6+
from a2a.client.errors import (
7+
A2AClientInvalidArgsError,
8+
A2AClientInvalidStateError,
9+
A2AClientJSONRPCError,
10+
A2AClientTimeoutError,
11+
)
12+
from a2a.types import JSONRPCError, JSONRPCErrorResponse
613

714

815
class TestA2AClientError:
@@ -35,6 +42,14 @@ def test_message_formatting(self) -> None:
3542
error = A2AClientHTTPError(500, 'Internal Server Error')
3643
assert str(error) == 'HTTP Error 500: Internal Server Error'
3744

45+
def test_repr(self) -> None:
46+
"""Test that __repr__ shows structured attributes."""
47+
error = A2AClientHTTPError(404, 'Not Found')
48+
assert (
49+
repr(error)
50+
== "A2AClientHTTPError(status_code=404, message='Not Found')"
51+
)
52+
3853
def test_inheritance(self) -> None:
3954
"""Test that A2AClientHTTPError inherits from A2AClientError."""
4055
error = A2AClientHTTPError(400, 'Bad Request')
@@ -81,6 +96,13 @@ def test_message_formatting(self) -> None:
8196
error = A2AClientJSONError('Missing required field')
8297
assert str(error) == 'JSON Error: Missing required field'
8398

99+
def test_repr(self) -> None:
100+
"""Test that __repr__ shows structured attributes."""
101+
error = A2AClientJSONError('Invalid JSON format')
102+
assert (
103+
repr(error) == "A2AClientJSONError(message='Invalid JSON format')"
104+
)
105+
84106
def test_inheritance(self) -> None:
85107
"""Test that A2AClientJSONError inherits from A2AClientError."""
86108
error = A2AClientJSONError('Parsing error')
@@ -108,6 +130,57 @@ def test_with_various_messages(self) -> None:
108130
assert str(error) == f'JSON Error: {message}'
109131

110132

133+
class TestA2AClientTimeoutErrorRepr:
134+
"""Test __repr__ for A2AClientTimeoutError."""
135+
136+
def test_repr(self) -> None:
137+
"""Test that __repr__ shows structured attributes."""
138+
error = A2AClientTimeoutError('Request timed out')
139+
assert (
140+
repr(error) == "A2AClientTimeoutError(message='Request timed out')"
141+
)
142+
143+
144+
class TestA2AClientInvalidArgsErrorRepr:
145+
"""Test __repr__ for A2AClientInvalidArgsError."""
146+
147+
def test_repr(self) -> None:
148+
"""Test that __repr__ shows structured attributes."""
149+
error = A2AClientInvalidArgsError('Missing required param')
150+
assert (
151+
repr(error)
152+
== "A2AClientInvalidArgsError(message='Missing required param')"
153+
)
154+
155+
156+
class TestA2AClientInvalidStateErrorRepr:
157+
"""Test __repr__ for A2AClientInvalidStateError."""
158+
159+
def test_repr(self) -> None:
160+
"""Test that __repr__ shows structured attributes."""
161+
error = A2AClientInvalidStateError('Client not initialized')
162+
assert (
163+
repr(error)
164+
== "A2AClientInvalidStateError(message='Client not initialized')"
165+
)
166+
167+
168+
class TestA2AClientJSONRPCErrorRepr:
169+
"""Test __repr__ for A2AClientJSONRPCError."""
170+
171+
def test_repr(self) -> None:
172+
"""Test that __repr__ shows the JSON-RPC error object."""
173+
response = JSONRPCErrorResponse(
174+
id='test-1',
175+
error=JSONRPCError(code=-32601, message='Method not found'),
176+
)
177+
error = A2AClientJSONRPCError(response)
178+
assert (
179+
repr(error)
180+
== "A2AClientJSONRPCError(JSONRPCError(code=-32601, data=None, message='Method not found'))"
181+
)
182+
183+
111184
class TestExceptionHierarchy:
112185
"""Test the exception hierarchy and relationships."""
113186

tests/server/request_handlers/test_default_request_handler.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2644,3 +2644,54 @@ async def test_on_message_send_stream_task_id_provided_but_task_not_found():
26442644
f'Task {task_id} was specified but does not exist'
26452645
in exc_info.value.error.message
26462646
)
2647+
2648+
2649+
class HelloWorldAgentExecutor(AgentExecutor):
2650+
"""Test Agent Implementation."""
2651+
2652+
async def execute(
2653+
self,
2654+
context: RequestContext,
2655+
event_queue: EventQueue,
2656+
) -> None:
2657+
updater = TaskUpdater(
2658+
event_queue,
2659+
task_id=context.task_id or str(uuid.uuid4()),
2660+
context_id=context.context_id or str(uuid.uuid4()),
2661+
)
2662+
await updater.update_status(TaskState.working)
2663+
await updater.complete()
2664+
2665+
async def cancel(
2666+
self, context: RequestContext, event_queue: EventQueue
2667+
) -> None:
2668+
raise NotImplementedError('cancel not supported')
2669+
2670+
2671+
# Repro is straight from the https://github.com/a2aproject/a2a-python/issues/609.
2672+
# It uses timeout to test against infinite wait, if it's going to be flaky,
2673+
# we should reconsider the approach.
2674+
@pytest.mark.asyncio
2675+
@pytest.mark.timeout(1)
2676+
async def test_on_message_send_error_does_not_hang():
2677+
"""Test that if the consumer raises an exception during blocking wait, the producer is cancelled and no deadlock occurs."""
2678+
agent = HelloWorldAgentExecutor()
2679+
task_store = AsyncMock(spec=TaskStore)
2680+
task_store.save.side_effect = RuntimeError('This is an Error!')
2681+
2682+
request_handler = DefaultRequestHandler(
2683+
agent_executor=agent, task_store=task_store
2684+
)
2685+
2686+
params = MessageSendParams(
2687+
message=Message(
2688+
role=Role.user,
2689+
message_id='msg_error_blocking',
2690+
parts=[Part(root=TextPart(text='Test message'))],
2691+
)
2692+
)
2693+
2694+
with pytest.raises(RuntimeError, match='This is an Error!'):
2695+
await request_handler.on_message_send(
2696+
params, create_server_call_context()
2697+
)

tests/server/request_handlers/test_jsonrpc_handler.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,6 @@ async def streaming_coro():
322322

323323
self.assertIsInstance(response.root, JSONRPCErrorResponse)
324324
assert response.root.error == UnsupportedOperationError() # type: ignore
325-
mock_agent_executor.execute.assert_called_once()
326325

327326
@patch(
328327
'a2a.server.agent_execution.simple_request_context_builder.SimpleRequestContextBuilder.build'

uv.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)