Skip to content

Commit fc69e82

Browse files
authored
Merge branch 'main' into chore/improve-coverage-grpc-client
2 parents 9c0a5be + 1cf8185 commit fc69e82

File tree

12 files changed

+511
-17
lines changed

12 files changed

+511
-17
lines changed

.gemini/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
code_review:
2+
comment_severity_threshold: LOW
3+
ignore_patterns: ['CHANGELOG.md']

.github/actions/spelling/allow.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ coc
2626
codegen
2727
coro
2828
datamodel
29+
deepwiki
2930
drivername
3031
DSNs
3132
dunders
@@ -80,5 +81,6 @@ tagwords
8081
taskupdate
8182
testuuid
8283
Tful
84+
tiangolo
8385
typeerror
8486
vulnz

.github/workflows/unit-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jobs:
5353
run: |
5454
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
5555
- name: Install dependencies
56-
run: uv sync --dev --extra sql --extra encryption --extra grpc --extra telemetry
56+
run: uv sync --dev --extra all
5757
- name: Run tests and check coverage
5858
run: uv run pytest --cov=a2a --cov-report term --cov-fail-under=88
5959
- name: Show coverage summary in log

.ruff.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ ignore = [
3232
"TRY003",
3333
"TRY201",
3434
"FIX002",
35+
"UP038",
3536
]
3637

3738
select = [

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## [0.3.5](https://github.com/a2aproject/a2a-python/compare/v0.3.4...v0.3.5) (2025-09-08)
4+
5+
6+
### Bug Fixes
7+
8+
* Prevent client disconnect from stopping task execution ([#440](https://github.com/a2aproject/a2a-python/issues/440)) ([58b4c81](https://github.com/a2aproject/a2a-python/commit/58b4c81746fc83e65f23f46308c47099697554ea)), closes [#296](https://github.com/a2aproject/a2a-python/issues/296)
9+
* **proto:** Adds metadata field to A2A DataPart proto ([#455](https://github.com/a2aproject/a2a-python/issues/455)) ([6d0ef59](https://github.com/a2aproject/a2a-python/commit/6d0ef593adaa22b2af0a5dd1a186646c180e3f8c))
10+
11+
12+
### Documentation
13+
14+
* add example docs for `[@validate](https://github.com/validate)` and `[@validate](https://github.com/validate)_async_generator` ([#422](https://github.com/a2aproject/a2a-python/issues/422)) ([18289eb](https://github.com/a2aproject/a2a-python/commit/18289eb19bbdaebe5e36e26be686e698f223160b))
15+
* Restructure README ([9758f78](https://github.com/a2aproject/a2a-python/commit/9758f7896c5497d6ca49f798296a7380b2134b29))
16+
317
## [0.3.4](https://github.com/a2aproject/a2a-python/compare/v0.3.3...v0.3.4) (2025-09-02)
418

519

README.md

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,20 +47,17 @@ Install the core SDK and any desired extras using your preferred package manager
4747
| Feature | `uv` Command | `pip` Command |
4848
| ------------------------ | ------------------------------------------ | -------------------------------------------- |
4949
| **Core SDK** | `uv add a2a-sdk` | `pip install a2a-sdk` |
50+
| **All Extras** | `uv add a2a-sdk[all]` | `pip install a2a-sdk[all]` |
5051
| **HTTP Server** | `uv add "a2a-sdk[http-server]"` | `pip install "a2a-sdk[http-server]"` |
5152
| **gRPC Support** | `uv add "a2a-sdk[grpc]"` | `pip install "a2a-sdk[grpc]"` |
5253
| **OpenTelemetry Tracing**| `uv add "a2a-sdk[telemetry]"` | `pip install "a2a-sdk[telemetry]"` |
53-
54-
#### Database Support
55-
56-
Install the necessary drivers for your chosen SQL database.
57-
58-
| Database | `uv` Command | `pip` Command |
59-
| ------------- | ---------------------------------- | ------------------------------------ |
60-
| **PostgreSQL**| `uv add "a2a-sdk[postgresql]"` | `pip install "a2a-sdk[postgresql]"` |
61-
| **MySQL** | `uv add "a2a-sdk[mysql]"` | `pip install "a2a-sdk[mysql]"` |
62-
| **SQLite** | `uv add "a2a-sdk[sqlite]"` | `pip install "a2a-sdk[sqlite]"` |
63-
| **All SQL Drivers** | `uv add "a2a-sdk[sql]"` | `pip install "a2a-sdk[sql]"` |
54+
| **Encryption** | `uv add "a2a-sdk[encryption]"` | `pip install "a2a-sdk[encryption]"` |
55+
| | | |
56+
| **Database Drivers** | | |
57+
| **PostgreSQL** | `uv add "a2a-sdk[postgresql]"` | `pip install "a2a-sdk[postgresql]"` |
58+
| **MySQL** | `uv add "a2a-sdk[mysql]"` | `pip install "a2a-sdk[mysql]"` |
59+
| **SQLite** | `uv add "a2a-sdk[sqlite]"` | `pip install "a2a-sdk[sqlite]"` |
60+
| **All SQL Drivers** | `uv add "a2a-sdk[sql]"` | `pip install "a2a-sdk[sql]"` |
6461

6562
## Examples
6663

pyproject.toml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,22 @@ classifiers = [
3030

3131
[project.optional-dependencies]
3232
http-server = ["fastapi>=0.115.2", "sse-starlette", "starlette"]
33-
postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"]
34-
mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"]
35-
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
36-
sql = ["sqlalchemy[asyncio,postgresql-asyncpg,aiomysql,aiosqlite]>=2.0.0"]
3733
encryption = ["cryptography>=43.0.0"]
3834
grpc = ["grpcio>=1.60", "grpcio-tools>=1.60", "grpcio_reflection>=1.7.0"]
3935
telemetry = ["opentelemetry-api>=1.33.0", "opentelemetry-sdk>=1.33.0"]
36+
postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"]
37+
mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"]
38+
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
39+
40+
sql = ["a2a-sdk[postgresql,mysql,sqlite]"]
41+
42+
all = [
43+
"a2a-sdk[http-server]",
44+
"a2a-sdk[sql]",
45+
"a2a-sdk[encryption]",
46+
"a2a-sdk[grpc]",
47+
"a2a-sdk[telemetry]",
48+
]
4049

4150
[project.urls]
4251
homepage = "https://a2a-protocol.org/"

src/a2a/server/apps/jsonrpc/jsonrpc_app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ async def _handle_requests(self, request: Request) -> Response: # noqa: PLR0911
337337

338338
# 3) Build call context and wrap the request for downstream handling
339339
call_context = self._context_builder.build(request)
340+
call_context.state['method'] = method
340341

341342
request_id = specific_request.id
342343
a2a_request = A2ARequest(root=specific_request)

src/a2a/utils/helpers.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,62 @@ def validate(
139139
and returns a boolean.
140140
error_message: An optional custom error message for the `UnsupportedOperationError`.
141141
If None, the string representation of the expression will be used.
142+
143+
Examples:
144+
Demonstrating with an async method:
145+
>>> import asyncio
146+
>>> from a2a.utils.errors import ServerError
147+
>>>
148+
>>> class MyAgent:
149+
... def __init__(self, streaming_enabled: bool):
150+
... self.streaming_enabled = streaming_enabled
151+
...
152+
... @validate(
153+
... lambda self: self.streaming_enabled,
154+
... 'Streaming is not enabled for this agent',
155+
... )
156+
... async def stream_response(self, message: str):
157+
... return f'Streaming: {message}'
158+
>>>
159+
>>> async def run_async_test():
160+
... # Successful call
161+
... agent_ok = MyAgent(streaming_enabled=True)
162+
... result = await agent_ok.stream_response('hello')
163+
... print(result)
164+
...
165+
... # Call that fails validation
166+
... agent_fail = MyAgent(streaming_enabled=False)
167+
... try:
168+
... await agent_fail.stream_response('world')
169+
... except ServerError as e:
170+
... print(e.error.message)
171+
>>>
172+
>>> asyncio.run(run_async_test())
173+
Streaming: hello
174+
Streaming is not enabled for this agent
175+
176+
Demonstrating with a sync method:
177+
>>> class SecureAgent:
178+
... def __init__(self):
179+
... self.auth_enabled = False
180+
...
181+
... @validate(
182+
... lambda self: self.auth_enabled,
183+
... 'Authentication must be enabled for this operation',
184+
... )
185+
... def secure_operation(self, data: str):
186+
... return f'Processing secure data: {data}'
187+
>>>
188+
>>> # Error case example
189+
>>> agent = SecureAgent()
190+
>>> try:
191+
... agent.secure_operation('secret')
192+
... except ServerError as e:
193+
... print(e.error.message)
194+
Authentication must be enabled for this operation
195+
196+
Note:
197+
This decorator works with both sync and async methods automatically.
142198
"""
143199

144200
def decorator(function: Callable) -> Callable:
@@ -174,7 +230,7 @@ def sync_wrapper(self: Any, *args, **kwargs) -> Any:
174230
def validate_async_generator(
175231
expression: Callable[[Any], bool], error_message: str | None = None
176232
):
177-
"""Decorator that validates if a given expression evaluates to True.
233+
"""Decorator that validates if a given expression evaluates to True for async generators.
178234
179235
Typically used on class methods to check capabilities or configuration
180236
before executing the method's logic. If the expression is False,
@@ -185,6 +241,60 @@ def validate_async_generator(
185241
and returns a boolean.
186242
error_message: An optional custom error message for the `UnsupportedOperationError`.
187243
If None, the string representation of the expression will be used.
244+
245+
Examples:
246+
Streaming capability validation with success case:
247+
>>> import asyncio
248+
>>> from a2a.utils.errors import ServerError
249+
>>>
250+
>>> class StreamingAgent:
251+
... def __init__(self, streaming_enabled: bool):
252+
... self.streaming_enabled = streaming_enabled
253+
...
254+
... @validate_async_generator(
255+
... lambda self: self.streaming_enabled,
256+
... 'Streaming is not supported by this agent',
257+
... )
258+
... async def stream_messages(self, count: int):
259+
... for i in range(count):
260+
... yield f'Message {i}'
261+
>>>
262+
>>> async def run_streaming_test():
263+
... # Successful streaming
264+
... agent = StreamingAgent(streaming_enabled=True)
265+
... async for msg in agent.stream_messages(2):
266+
... print(msg)
267+
>>>
268+
>>> asyncio.run(run_streaming_test())
269+
Message 0
270+
Message 1
271+
272+
Error case - validation fails:
273+
>>> class FeatureAgent:
274+
... def __init__(self):
275+
... self.features = {'real_time': False}
276+
...
277+
... @validate_async_generator(
278+
... lambda self: self.features.get('real_time', False),
279+
... 'Real-time feature must be enabled to stream updates',
280+
... )
281+
... async def real_time_updates(self):
282+
... yield 'This should not be yielded'
283+
>>>
284+
>>> async def run_error_test():
285+
... agent = FeatureAgent()
286+
... try:
287+
... async for _ in agent.real_time_updates():
288+
... pass
289+
... except ServerError as e:
290+
... print(e.error.message)
291+
>>>
292+
>>> asyncio.run(run_error_test())
293+
Real-time feature must be enabled to stream updates
294+
295+
Note:
296+
This decorator is specifically for async generator methods (async def with yield).
297+
The validation happens before the generator starts yielding values.
188298
"""
189299

190300
def decorator(function):

src/a2a/utils/proto_utils.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,86 @@ def dict_to_struct(dictionary: dict[str, Any]) -> struct_pb2.Struct:
4646
return struct
4747

4848

49+
def make_dict_serializable(value: Any) -> Any:
50+
"""Dict pre-processing utility: converts non-serializable values to serializable form.
51+
52+
Use this when you want to normalize a dictionary before dict->Struct conversion.
53+
54+
Args:
55+
value: The value to convert.
56+
57+
Returns:
58+
A serializable value.
59+
"""
60+
if isinstance(value, (str, int, float, bool)) or value is None:
61+
return value
62+
if isinstance(value, dict):
63+
return {k: make_dict_serializable(v) for k, v in value.items()}
64+
if isinstance(value, list | tuple):
65+
return [make_dict_serializable(item) for item in value]
66+
return str(value)
67+
68+
69+
def normalize_large_integers_to_strings(
70+
value: Any, max_safe_digits: int = 15
71+
) -> Any:
72+
"""Integer preprocessing utility: converts large integers to strings.
73+
74+
Use this when you want to convert large integers to strings considering
75+
JavaScript's MAX_SAFE_INTEGER (2^53 - 1) limitation.
76+
77+
Args:
78+
value: The value to convert.
79+
max_safe_digits: Maximum safe integer digits (default: 15).
80+
81+
Returns:
82+
A normalized value.
83+
"""
84+
max_safe_int = 10**max_safe_digits - 1
85+
86+
def _normalize(item: Any) -> Any:
87+
if isinstance(item, int) and abs(item) > max_safe_int:
88+
return str(item)
89+
if isinstance(item, dict):
90+
return {k: _normalize(v) for k, v in item.items()}
91+
if isinstance(item, list | tuple):
92+
return [_normalize(i) for i in item]
93+
return item
94+
95+
return _normalize(value)
96+
97+
98+
def parse_string_integers_in_dict(value: Any, max_safe_digits: int = 15) -> Any:
99+
"""String post-processing utility: converts large integer strings back to integers.
100+
101+
Use this when you want to restore large integer strings to integers
102+
after Struct->dict conversion.
103+
104+
Args:
105+
value: The value to convert.
106+
max_safe_digits: Maximum safe integer digits (default: 15).
107+
108+
Returns:
109+
A parsed value.
110+
"""
111+
if isinstance(value, dict):
112+
return {
113+
k: parse_string_integers_in_dict(v, max_safe_digits)
114+
for k, v in value.items()
115+
}
116+
if isinstance(value, list | tuple):
117+
return [
118+
parse_string_integers_in_dict(item, max_safe_digits)
119+
for item in value
120+
]
121+
if isinstance(value, str):
122+
# Handle potential negative numbers.
123+
stripped_value = value.lstrip('-')
124+
if stripped_value.isdigit() and len(stripped_value) > max_safe_digits:
125+
return int(value)
126+
return value
127+
128+
49129
class ToProto:
50130
"""Converts Python types to proto types."""
51131

0 commit comments

Comments
 (0)