Skip to content

Commit a258007

Browse files
authored
Merge branch 'main' into fix-proto-utils-metadata-serialization
2 parents ef3b263 + d62df7a commit a258007

File tree

5 files changed

+148
-3
lines changed

5 files changed

+148
-3
lines changed

.github/workflows/python-publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
- uses: actions/checkout@v5
1616

1717
- name: Install uv
18-
uses: astral-sh/setup-uv@v5
18+
uses: astral-sh/setup-uv@v6
1919

2020
- name: "Set up Python"
2121
uses: actions/setup-python@v5
@@ -40,7 +40,7 @@ jobs:
4040

4141
steps:
4242
- name: Retrieve release distributions
43-
uses: actions/download-artifact@v4
43+
uses: actions/download-artifact@v5
4444
with:
4545
name: release-dists
4646
path: dist/

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

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):

tests/server/apps/jsonrpc/test_jsonrpc_app.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,26 @@ def test_request_with_comma_separated_extensions_no_space(
289289
call_context = mock_handler.on_message_send.call_args[0][1]
290290
assert call_context.requested_extensions == {'foo', 'bar', 'baz'}
291291

292+
def test_method_added_to_call_context_state(self, client, mock_handler):
293+
response = client.post(
294+
'/',
295+
json=SendMessageRequest(
296+
id='1',
297+
params=MessageSendParams(
298+
message=Message(
299+
message_id='1',
300+
role=Role.user,
301+
parts=[Part(TextPart(text='hi'))],
302+
)
303+
),
304+
).model_dump(),
305+
)
306+
response.raise_for_status()
307+
308+
mock_handler.on_message_send.assert_called_once()
309+
call_context = mock_handler.on_message_send.call_args[0][1]
310+
assert call_context.state['method'] == 'message/send'
311+
292312
def test_request_with_multiple_extension_headers(
293313
self, client, mock_handler
294314
):

0 commit comments

Comments
 (0)