From 01b421ec41560df5bf10005f09fb49b23ec76ced Mon Sep 17 00:00:00 2001 From: sokoliva Date: Thu, 23 Oct 2025 14:36:16 +0000 Subject: [PATCH 1/4] fix: change "client/test_client.py" to "client/test_client_factory.py" in Running the tests instructions. "client/test_client_factory.py" no longer exists. --- tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index bab99450..d89f3bec 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,7 +2,7 @@ 1. Run the tests ```bash - uv run pytest -v -s client/test_client.py + uv run pytest -v -s client/test_client_factory.py ``` In case of failures, you can cleanup the cache: From 063cc16251f8e721d6b25999418a32f542d6fc39 Mon Sep 17 00:00:00 2001 From: sokoliva Date: Mon, 15 Dec 2025 15:56:33 +0000 Subject: [PATCH 2/4] refactor: add signature verification to `A2ACardResolver` `get_agent_card` method. --- src/a2a/client/card_resolver.py | 5 +++++ src/a2a/client/client_factory.py | 4 ++++ src/a2a/client/transports/grpc.py | 2 +- src/a2a/client/transports/jsonrpc.py | 9 +++++---- src/a2a/client/transports/rest.py | 9 +++++---- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/a2a/client/card_resolver.py b/src/a2a/client/card_resolver.py index f13fe3ab..adb3c5ae 100644 --- a/src/a2a/client/card_resolver.py +++ b/src/a2a/client/card_resolver.py @@ -1,6 +1,7 @@ import json import logging +from collections.abc import Callable from typing import Any import httpx @@ -44,6 +45,7 @@ async def get_agent_card( self, relative_card_path: str | None = None, http_kwargs: dict[str, Any] | None = None, + signature_verifier: Callable[[AgentCard], None] | None = None, ) -> AgentCard: """Fetches an agent card from a specified path relative to the base_url. @@ -56,6 +58,7 @@ async def get_agent_card( agent card path. Use `'/'` for an empty path. http_kwargs: Optional dictionary of keyword arguments to pass to the underlying httpx.get request. + signature_verifier: A callable used to verify the agent card's signatures. Returns: An `AgentCard` object representing the agent's capabilities. @@ -86,6 +89,8 @@ async def get_agent_card( agent_card_data, ) agent_card = AgentCard.model_validate(agent_card_data) + if signature_verifier: + signature_verifier(agent_card) except httpx.HTTPStatusError as e: raise A2AClientHTTPError( e.response.status_code, diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index e2eb066a..c3d5762e 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -116,6 +116,7 @@ async def connect( # noqa: PLR0913 resolver_http_kwargs: dict[str, Any] | None = None, extra_transports: dict[str, TransportProducer] | None = None, extensions: list[str] | None = None, + signature_verifier: Callable[[AgentCard], None] | None = None, ) -> Client: """Convenience method for constructing a client. @@ -146,6 +147,7 @@ async def connect( # noqa: PLR0913 extra_transports: Additional transport protocols to enable when constructing the client. extensions: List of extensions to be activated. + signature_verifier: A callable used to verify the agent card's signatures. Returns: A `Client` object. @@ -158,12 +160,14 @@ async def connect( # noqa: PLR0913 card = await resolver.get_agent_card( relative_card_path=relative_card_path, http_kwargs=resolver_http_kwargs, + signature_verifier=signature_verifier, ) else: resolver = A2ACardResolver(client_config.httpx_client, agent) card = await resolver.get_agent_card( relative_card_path=relative_card_path, http_kwargs=resolver_http_kwargs, + signature_verifier=signature_verifier, ) else: card = agent diff --git a/src/a2a/client/transports/grpc.py b/src/a2a/client/transports/grpc.py index c5edf7a1..6a8b16f9 100644 --- a/src/a2a/client/transports/grpc.py +++ b/src/a2a/client/transports/grpc.py @@ -237,7 +237,7 @@ async def get_card( metadata=self._get_grpc_metadata(extensions), ) card = proto_utils.FromProto.agent_card(card_pb) - if signature_verifier is not None: + if signature_verifier: signature_verifier(card) self.agent_card = card diff --git a/src/a2a/client/transports/jsonrpc.py b/src/a2a/client/transports/jsonrpc.py index 54c758ff..a565e640 100644 --- a/src/a2a/client/transports/jsonrpc.py +++ b/src/a2a/client/transports/jsonrpc.py @@ -390,9 +390,10 @@ async def get_card( if not card: resolver = A2ACardResolver(self.httpx_client, self.url) - card = await resolver.get_agent_card(http_kwargs=modified_kwargs) - if signature_verifier is not None: - signature_verifier(card) + card = await resolver.get_agent_card( + http_kwargs=modified_kwargs, + signature_verifier=signature_verifier, + ) self._needs_extended_card = ( card.supports_authenticated_extended_card ) @@ -418,7 +419,7 @@ async def get_card( if isinstance(response.root, JSONRPCErrorResponse): raise A2AClientJSONRPCError(response.root) card = response.root.result - if signature_verifier is not None: + if signature_verifier: signature_verifier(card) self.agent_card = card diff --git a/src/a2a/client/transports/rest.py b/src/a2a/client/transports/rest.py index 1649be1c..afc9dd08 100644 --- a/src/a2a/client/transports/rest.py +++ b/src/a2a/client/transports/rest.py @@ -382,9 +382,10 @@ async def get_card( if not card: resolver = A2ACardResolver(self.httpx_client, self.url) - card = await resolver.get_agent_card(http_kwargs=modified_kwargs) - if signature_verifier is not None: - signature_verifier(card) + card = await resolver.get_agent_card( + http_kwargs=modified_kwargs, + signature_verifier=signature_verifier, + ) self._needs_extended_card = ( card.supports_authenticated_extended_card ) @@ -402,7 +403,7 @@ async def get_card( '/v1/card', {}, modified_kwargs ) card = AgentCard.model_validate(response_data) - if signature_verifier is not None: + if signature_verifier: signature_verifier(card) self.agent_card = card From 5b54ce6dbe09afb7a9da25fe06c430d08edf0f9e Mon Sep 17 00:00:00 2001 From: sokoliva Date: Mon, 15 Dec 2025 16:09:32 +0000 Subject: [PATCH 3/4] fix: fix tests --- tests/client/test_client_factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/client/test_client_factory.py b/tests/client/test_client_factory.py index 16a1433f..c388974b 100644 --- a/tests/client/test_client_factory.py +++ b/tests/client/test_client_factory.py @@ -190,6 +190,7 @@ async def test_client_factory_connect_with_resolver_args( mock_resolver.return_value.get_agent_card.assert_awaited_once_with( relative_card_path=relative_path, http_kwargs=http_kwargs, + signature_verifier=None, ) @@ -216,6 +217,7 @@ async def test_client_factory_connect_resolver_args_without_client( mock_resolver.return_value.get_agent_card.assert_awaited_once_with( relative_card_path=relative_path, http_kwargs=http_kwargs, + signature_verifier=None, ) From 1f75ef7fc3486c8e48ae865149543f4dcdbfb52c Mon Sep 17 00:00:00 2001 From: sokoliva Date: Tue, 16 Dec 2025 12:02:13 +0000 Subject: [PATCH 4/4] test(client): add unit tests for A2ACardResolver --- tests/client/test_card_resolver.py | 170 +++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 tests/client/test_card_resolver.py diff --git a/tests/client/test_card_resolver.py b/tests/client/test_card_resolver.py new file mode 100644 index 00000000..8ed2b781 --- /dev/null +++ b/tests/client/test_card_resolver.py @@ -0,0 +1,170 @@ +"""Tests for the A2ACardResolver.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from a2a.client.card_resolver import A2ACardResolver +from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError + +from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH + + +@pytest.fixture +def mock_httpx_client() -> AsyncMock: + """Provides a mock httpx.AsyncClient.""" + return AsyncMock(spec=httpx.AsyncClient) + + +@pytest.fixture +def base_agent_card_data() -> dict: + """Provides base valid agent card data.""" + return { + 'name': 'Test Agent', + 'description': 'An agent for testing.', + 'url': 'http://example.com', + 'version': '1.0.0', + 'capabilities': {}, + 'skills': [], + 'default_input_modes': [], + 'default_output_modes': [], + 'preferred_transport': 'jsonrpc', + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'relative_path, expected_path_segment', + [ + (None, AGENT_CARD_WELL_KNOWN_PATH), + ('/custom/card', '/custom/card'), + ('', AGENT_CARD_WELL_KNOWN_PATH), + ], +) +async def test_get_agent_card_success( + mock_httpx_client: AsyncMock, + base_agent_card_data: dict, + relative_path: str | None, + expected_path_segment: str, +): + """Test successful agent card retrieval using default and relative paths.""" + base_url = 'http://example.com' + resolver = A2ACardResolver(mock_httpx_client, base_url) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = base_agent_card_data + mock_httpx_client.get.return_value = mock_response + + agent_card = await resolver.get_agent_card(relative_card_path=relative_path) + + expected_url = f'{base_url}{expected_path_segment}' + mock_httpx_client.get.assert_awaited_once_with(expected_url) + mock_response.raise_for_status.assert_called_once() + assert agent_card.name == base_agent_card_data['name'] + assert agent_card.url == base_agent_card_data['url'] + + +@pytest.mark.asyncio +async def test_get_agent_card_http_error(mock_httpx_client: AsyncMock): + """Test handling of HTTP errors during agent card retrieval.""" + base_url = 'http://example.com' + resolver = A2ACardResolver(mock_httpx_client, base_url) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + 'Not Found', request=MagicMock(), response=mock_response + ) + mock_response.status_code = 404 + mock_httpx_client.get.return_value = mock_response + + with pytest.raises(A2AClientHTTPError) as excinfo: + await resolver.get_agent_card() + assert excinfo.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_agent_card_json_decode_error(mock_httpx_client: AsyncMock): + """Test handling of JSON decoding errors.""" + base_url = 'http://example.com' + resolver = A2ACardResolver(mock_httpx_client, base_url) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.side_effect = json.JSONDecodeError('msg', 'doc', 0) + mock_httpx_client.get.return_value = mock_response + + with pytest.raises(A2AClientJSONError, match='Failed to parse JSON'): + await resolver.get_agent_card() + + +@pytest.mark.asyncio +async def test_get_agent_card_network_error(mock_httpx_client: AsyncMock): + """Test handling of network communication errors.""" + base_url = 'http://example.com' + resolver = A2ACardResolver(mock_httpx_client, base_url) + + mock_httpx_client.get.side_effect = httpx.RequestError('Network error') + + with pytest.raises(A2AClientHTTPError, match='Network communication error'): + await resolver.get_agent_card() + + +@pytest.mark.asyncio +async def test_get_agent_card_validation_error( + mock_httpx_client: AsyncMock, base_agent_card_data: dict +): + """Test handling of Pydantic validation errors.""" + base_url = 'http://example.com' + resolver = A2ACardResolver(mock_httpx_client, base_url) + + invalid_card_data = base_agent_card_data.copy() + del invalid_card_data['name'] # Make it invalid + + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = invalid_card_data + mock_httpx_client.get.return_value = mock_response + + with pytest.raises( + A2AClientJSONError, match='Failed to validate agent card structure' + ): + await resolver.get_agent_card() + + +@pytest.mark.asyncio +async def test_get_agent_card_with_http_kwargs( + mock_httpx_client: AsyncMock, base_agent_card_data: dict +): + """Test that http_kwargs are passed to the httpx client.""" + base_url = 'http://example.com' + resolver = A2ACardResolver(mock_httpx_client, base_url) + http_kwargs = {'headers': {'X-Test': 'true'}} + + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = base_agent_card_data + mock_httpx_client.get.return_value = mock_response + + await resolver.get_agent_card(http_kwargs=http_kwargs) + + expected_url = f'{base_url}{AGENT_CARD_WELL_KNOWN_PATH}' + mock_httpx_client.get.assert_awaited_once_with( + expected_url, headers={'X-Test': 'true'} + ) + + +@pytest.mark.asyncio +async def test_get_agent_card_with_signature_verifier( + mock_httpx_client: AsyncMock, base_agent_card_data: dict +): + """Test that the signature verifier is called if provided.""" + base_url = 'http://example.com' + resolver = A2ACardResolver(mock_httpx_client, base_url) + mock_verifier = MagicMock() + + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = base_agent_card_data + mock_httpx_client.get.return_value = mock_response + + agent_card = await resolver.get_agent_card(signature_verifier=mock_verifier) + + mock_verifier.assert_called_once_with(agent_card)