Skip to content

Commit d3e1027

Browse files
committed
Moves types around to avoid circular dependencies and make grpc module truly optional
- Tested by running tests without installing grpc optional dependency. Only grpc tests fails in this case.
1 parent 95dc15e commit d3e1027

File tree

8 files changed

+149
-121
lines changed

8 files changed

+149
-121
lines changed

src/a2a/client/__init__.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
CredentialService,
88
InMemoryContextCredentialStore,
99
)
10+
from a2a.client.card_resolver import A2ACardResolver
1011
from a2a.client.client import (
11-
A2ACardResolver,
1212
Client,
1313
ClientConfig,
1414
ClientEvent,
@@ -27,6 +27,7 @@
2727
)
2828
from a2a.client.helpers import create_text_message_object
2929
from a2a.client.jsonrpc_client import (
30+
A2AClient,
3031
JsonRpcClient,
3132
JsonRpcTransportClient,
3233
NewJsonRpcClient,
@@ -39,10 +40,6 @@
3940
)
4041

4142

42-
# For backward compatability define this alias. This will be deprecated in
43-
# a future release.
44-
A2AClient = JsonRpcTransportClient
45-
4643
logger = logging.getLogger(__name__)
4744

4845
try:

src/a2a/client/card_resolver.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import json
2+
import logging
3+
4+
from typing import Any
5+
6+
import httpx
7+
8+
from pydantic import ValidationError
9+
10+
from a2a.client.errors import (
11+
A2AClientHTTPError,
12+
A2AClientJSONError,
13+
)
14+
from a2a.types import (
15+
AgentCard,
16+
)
17+
from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH
18+
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
class A2ACardResolver:
24+
"""Agent Card resolver."""
25+
26+
def __init__(
27+
self,
28+
httpx_client: httpx.AsyncClient,
29+
base_url: str,
30+
agent_card_path: str = AGENT_CARD_WELL_KNOWN_PATH,
31+
) -> None:
32+
"""Initializes the A2ACardResolver.
33+
34+
Args:
35+
httpx_client: An async HTTP client instance (e.g., httpx.AsyncClient).
36+
base_url: The base URL of the agent's host.
37+
agent_card_path: The path to the agent card endpoint, relative to the base URL.
38+
"""
39+
self.base_url = base_url.rstrip('/')
40+
self.agent_card_path = agent_card_path.lstrip('/')
41+
self.httpx_client = httpx_client
42+
43+
async def get_agent_card(
44+
self,
45+
relative_card_path: str | None = None,
46+
http_kwargs: dict[str, Any] | None = None,
47+
) -> AgentCard:
48+
"""Fetches an agent card from a specified path relative to the base_url.
49+
50+
If relative_card_path is None, it defaults to the resolver's configured
51+
agent_card_path (for the public agent card).
52+
53+
Args:
54+
relative_card_path: Optional path to the agent card endpoint,
55+
relative to the base URL. If None, uses the default public
56+
agent card path.
57+
http_kwargs: Optional dictionary of keyword arguments to pass to the
58+
underlying httpx.get request.
59+
60+
Returns:
61+
An `AgentCard` object representing the agent's capabilities.
62+
63+
Raises:
64+
A2AClientHTTPError: If an HTTP error occurs during the request.
65+
A2AClientJSONError: If the response body cannot be decoded as JSON
66+
or validated against the AgentCard schema.
67+
"""
68+
if relative_card_path is None:
69+
# Use the default public agent card path configured during initialization
70+
path_segment = self.agent_card_path
71+
else:
72+
path_segment = relative_card_path.lstrip('/')
73+
74+
target_url = f'{self.base_url}/{path_segment}'
75+
76+
try:
77+
response = await self.httpx_client.get(
78+
target_url,
79+
**(http_kwargs or {}),
80+
)
81+
response.raise_for_status()
82+
agent_card_data = response.json()
83+
logger.info(
84+
'Successfully fetched agent card data from %s: %s',
85+
target_url,
86+
agent_card_data,
87+
)
88+
agent_card = AgentCard.model_validate(agent_card_data)
89+
except httpx.HTTPStatusError as e:
90+
raise A2AClientHTTPError(
91+
e.response.status_code,
92+
f'Failed to fetch agent card from {target_url}: {e}',
93+
) from e
94+
except json.JSONDecodeError as e:
95+
raise A2AClientJSONError(
96+
f'Failed to parse JSON for agent card from {target_url}: {e}'
97+
) from e
98+
except httpx.RequestError as e:
99+
raise A2AClientHTTPError(
100+
503,
101+
f'Network communication error fetching agent card from {target_url}: {e}',
102+
) from e
103+
except ValidationError as e: # Pydantic validation error
104+
raise A2AClientJSONError(
105+
f'Failed to validate agent card structure from {target_url}: {e.json()}'
106+
) from e
107+
108+
return agent_card

src/a2a/client/client.py

Lines changed: 2 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,14 @@
11
import dataclasses
2-
import json
32
import logging
43

54
from abc import ABC, abstractmethod
65
from collections.abc import AsyncIterator, Callable, Coroutine
7-
from typing import TYPE_CHECKING, Any
6+
from typing import Any
87

98
import httpx
109

11-
from pydantic import ValidationError
12-
13-
14-
# Attempt to import the optional module
15-
try:
16-
from grpc.aio import Channel
17-
except ImportError:
18-
# If grpc.aio is not available, define a dummy type for type checking.
19-
# This dummy type will only be used by type checkers.
20-
if TYPE_CHECKING:
21-
22-
class Channel: # type: ignore[no-redef]
23-
"""Dummy class for type hinting when grpc.aio is not available."""
24-
25-
else:
26-
Channel = None # At runtime, pd will be None if the import failed.
27-
28-
from a2a.client.errors import (
29-
A2AClientHTTPError,
30-
A2AClientJSONError,
31-
)
3210
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
11+
from a2a.client.optionals import Channel
3312
from a2a.types import (
3413
AgentCard,
3514
GetTaskPushNotificationConfigParams,
@@ -43,100 +22,11 @@ class Channel: # type: ignore[no-redef]
4322
TaskStatusUpdateEvent,
4423
TransportProtocol,
4524
)
46-
from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH
4725

4826

4927
logger = logging.getLogger(__name__)
5028

5129

52-
class A2ACardResolver:
53-
"""Agent Card resolver."""
54-
55-
def __init__(
56-
self,
57-
httpx_client: httpx.AsyncClient,
58-
base_url: str,
59-
agent_card_path: str = AGENT_CARD_WELL_KNOWN_PATH,
60-
) -> None:
61-
"""Initializes the A2ACardResolver.
62-
63-
Args:
64-
httpx_client: An async HTTP client instance (e.g., httpx.AsyncClient).
65-
base_url: The base URL of the agent's host.
66-
agent_card_path: The path to the agent card endpoint, relative to the base URL.
67-
"""
68-
self.base_url = base_url.rstrip('/')
69-
self.agent_card_path = agent_card_path.lstrip('/')
70-
self.httpx_client = httpx_client
71-
72-
async def get_agent_card(
73-
self,
74-
relative_card_path: str | None = None,
75-
http_kwargs: dict[str, Any] | None = None,
76-
) -> AgentCard:
77-
"""Fetches an agent card from a specified path relative to the base_url.
78-
79-
If relative_card_path is None, it defaults to the resolver's configured
80-
agent_card_path (for the public agent card).
81-
82-
Args:
83-
relative_card_path: Optional path to the agent card endpoint,
84-
relative to the base URL. If None, uses the default public
85-
agent card path.
86-
http_kwargs: Optional dictionary of keyword arguments to pass to the
87-
underlying httpx.get request.
88-
89-
Returns:
90-
An `AgentCard` object representing the agent's capabilities.
91-
92-
Raises:
93-
A2AClientHTTPError: If an HTTP error occurs during the request.
94-
A2AClientJSONError: If the response body cannot be decoded as JSON
95-
or validated against the AgentCard schema.
96-
"""
97-
if relative_card_path is None:
98-
# Use the default public agent card path configured during initialization
99-
path_segment = self.agent_card_path
100-
else:
101-
path_segment = relative_card_path.lstrip('/')
102-
103-
target_url = f'{self.base_url}/{path_segment}'
104-
105-
try:
106-
response = await self.httpx_client.get(
107-
target_url,
108-
**(http_kwargs or {}),
109-
)
110-
response.raise_for_status()
111-
agent_card_data = response.json()
112-
logger.info(
113-
'Successfully fetched agent card data from %s: %s',
114-
target_url,
115-
agent_card_data,
116-
)
117-
agent_card = AgentCard.model_validate(agent_card_data)
118-
except httpx.HTTPStatusError as e:
119-
raise A2AClientHTTPError(
120-
e.response.status_code,
121-
f'Failed to fetch agent card from {target_url}: {e}',
122-
) from e
123-
except json.JSONDecodeError as e:
124-
raise A2AClientJSONError(
125-
f'Failed to parse JSON for agent card from {target_url}: {e}'
126-
) from e
127-
except httpx.RequestError as e:
128-
raise A2AClientHTTPError(
129-
503,
130-
f'Network communication error fetching agent card from {target_url}: {e}',
131-
) from e
132-
except ValidationError as e: # Pydantic validation error
133-
raise A2AClientJSONError(
134-
f'Failed to validate agent card structure from {target_url}: {e.json()}'
135-
) from e
136-
137-
return agent_card
138-
139-
14030
@dataclasses.dataclass
14131
class ClientConfig:
14232
"""Configuration class for the A2AClient Factory."""

src/a2a/client/client_factory.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
from collections.abc import Callable
66

77
from a2a.client.client import Client, ClientConfig, Consumer
8-
from a2a.client.grpc_client import NewGrpcClient
8+
9+
10+
try:
11+
from a2a.client.grpc_client import NewGrpcClient
12+
except ImportError:
13+
NewGrpcClient = None
914
from a2a.client.jsonrpc_client import NewJsonRpcClient
1015
from a2a.client.middleware import ClientCallInterceptor
1116
from a2a.client.rest_client import NewRestfulClient
@@ -63,6 +68,11 @@ def __init__(
6368
if TransportProtocol.http_json in self._config.supported_transports:
6469
self._registry[TransportProtocol.http_json] = NewRestfulClient
6570
if TransportProtocol.grpc in self._config.supported_transports:
71+
if NewGrpcClient is None:
72+
raise ImportError(
73+
'To use GrpcClient, its dependencies must be installed. '
74+
'You can install them with \'pip install "a2a-sdk[grpc]"\''
75+
)
6676
self._registry[TransportProtocol.grpc] = NewGrpcClient
6777

6878
def register(self, label: str, generator: ClientProducer) -> None:

src/a2a/client/jsonrpc_client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
from httpx_sse import SSEError, aconnect_sse
1111

12+
from a2a.client.card_resolver import A2ACardResolver
1213
from a2a.client.client import (
13-
A2ACardResolver,
1414
Client,
1515
ClientConfig,
1616
Consumer,
@@ -825,3 +825,8 @@ def NewJsonRpcClient( # noqa: N802
825825
) -> Client:
826826
"""Factory function for the `JsonRpcClient` implementation."""
827827
return JsonRpcClient(card, config, consumers, middleware)
828+
829+
830+
# For backward compatability define this alias. This will be deprecated in
831+
# a future release.
832+
A2AClient = JsonRpcTransportClient

src/a2a/client/optionals.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import TYPE_CHECKING
2+
3+
4+
# Attempt to import the optional module
5+
try:
6+
from grpc.aio import Channel
7+
except ImportError:
8+
# If grpc.aio is not available, define a dummy type for type checking.
9+
# This dummy type will only be used by type checkers.
10+
if TYPE_CHECKING:
11+
12+
class Channel: # type: ignore[no-redef]
13+
"""Dummy class for type hinting when grpc.aio is not available."""
14+
15+
else:
16+
Channel = None # At runtime, pd will be None if the import failed.

src/a2a/client/rest_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from google.protobuf.json_format import MessageToDict, Parse
1010
from httpx_sse import SSEError, aconnect_sse
1111

12-
from a2a.client.client import A2ACardResolver, Client, ClientConfig, Consumer
12+
from a2a.client.card_resolver import A2ACardResolver
13+
from a2a.client.client import Client, ClientConfig, Consumer
1314
from a2a.client.client_task_manager import ClientTaskManager
1415
from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError
1516
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor

tests/client/test_auth_middleware.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
import pytest
77
import respx
88

9-
from a2a.client import A2AClient, ClientCallContext, ClientCallInterceptor
9+
from a2a.client import A2AClient
10+
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
1011
from a2a.client.auth import AuthInterceptor, InMemoryContextCredentialStore
1112
from a2a.types import (
1213
APIKeySecurityScheme,

0 commit comments

Comments
 (0)