Skip to content

Commit d80ec70

Browse files
committed
Merge branch '1.0-dev' into ishymko/845-validation
2 parents a5f48cb + 2b323d0 commit d80ec70

19 files changed

Lines changed: 849 additions & 515 deletions

File tree

.github/actions/spelling/allow.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ cls
3535
coc
3636
codegen
3737
coro
38+
culsans
3839
datamodel
3940
deepwiki
4041
drivername
@@ -127,7 +128,7 @@ taskupdate
127128
testuuid
128129
Tful
129130
tiangolo
131+
TResponse
130132
typ
131133
typeerror
132134
vulnz
133-
TResponse

.github/workflows/unit-tests.yml

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
3939
strategy:
4040
matrix:
41-
python-version: ['3.10', '3.13']
41+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
4242
steps:
4343
- name: Checkout code
4444
uses: actions/checkout@v6
@@ -56,9 +56,9 @@ jobs:
5656
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
5757
5858
59-
# Coverage comparison for PRs (only on Python 3.13 to avoid duplicate work)
59+
# Coverage comparison for PRs (only on Python 3.14 to avoid duplicate work)
6060
- name: Checkout Base Branch
61-
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
61+
if: github.event_name == 'pull_request' && matrix.python-version == '3.14'
6262
uses: actions/checkout@v4
6363
with:
6464
ref: ${{ github.event.pull_request.base.ref || 'main' }}
@@ -68,33 +68,33 @@ jobs:
6868
run: uv sync --locked
6969

7070
- name: Run coverage (Base)
71-
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
71+
if: github.event_name == 'pull_request' && matrix.python-version == '3.14'
7272
run: |
7373
uv run pytest --cov=a2a --cov-report=json --cov-report=html:coverage
7474
mv coverage.json /tmp/coverage-base.json
7575
7676
- name: Checkout PR Branch (Restore)
77-
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
77+
if: github.event_name == 'pull_request' && matrix.python-version == '3.14'
7878
uses: actions/checkout@v4
7979
with:
8080
clean: true
8181

8282
- name: Run coverage (PR)
83-
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
83+
if: github.event_name == 'pull_request' && matrix.python-version == '3.14'
8484
run: |
8585
uv run pytest --cov=a2a --cov-report=json --cov-report=html:coverage --cov-report=term --cov-fail-under=88
8686
mv coverage.json coverage-pr.json
8787
cp /tmp/coverage-base.json coverage-base.json
8888
8989
- name: Save Metadata
90-
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
90+
if: github.event_name == 'pull_request' && matrix.python-version == '3.14'
9191
run: |
9292
echo ${{ github.event.number }} > ./PR_NUMBER
9393
echo ${{ github.event.pull_request.base.ref || 'main' }} > ./BASE_BRANCH
9494
9595
- name: Upload Coverage Artifacts
9696
uses: actions/upload-artifact@v4
97-
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
97+
if: github.event_name == 'pull_request' && matrix.python-version == '3.14'
9898
with:
9999
name: coverage-data
100100
path: |
@@ -107,12 +107,12 @@ jobs:
107107

108108
# Run standard tests (for matrix items that didn't run coverage PR)
109109
- name: Run tests (Standard)
110-
if: matrix.python-version != '3.13' || github.event_name != 'pull_request'
110+
if: matrix.python-version != '3.14' || github.event_name != 'pull_request'
111111
run: uv run pytest --cov=a2a --cov-report term --cov-fail-under=88
112112

113113
- name: Upload Artifact (base)
114114
uses: actions/upload-artifact@v4
115-
if: github.event_name != 'pull_request' && matrix.python-version == '3.13'
115+
if: github.event_name != 'pull_request' && matrix.python-version == '3.14'
116116
with:
117117
name: coverage-report
118118
path: coverage

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"google-api-core>=1.26.0",
1616
"json-rpc>=1.15.0",
1717
"googleapis-common-protos>=1.70.0",
18+
"culsans>=0.11.0 ; python_full_version < '3.13'",
1819
]
1920

2021
classifiers = [
@@ -25,6 +26,7 @@ classifiers = [
2526
"Programming Language :: Python :: 3.11",
2627
"Programming Language :: Python :: 3.12",
2728
"Programming Language :: Python :: 3.13",
29+
"Programming Language :: Python :: 3.14",
2830
"Operating System :: OS Independent",
2931
"Topic :: Software Development :: Libraries :: Python Modules",
3032
"License :: OSI Approved :: Apache Software License",

src/a2a/client/transports/http_helpers.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,22 @@ async def send_http_stream_request(
7878
async with aconnect_sse(
7979
httpx_client, method, url, **kwargs
8080
) as event_source:
81-
event_source.response.raise_for_status()
81+
try:
82+
event_source.response.raise_for_status()
83+
except httpx.HTTPStatusError as e:
84+
# Read upfront streaming error content immediately, otherwise lower-level handlers
85+
# (e.g. response.json()) crash with 'ResponseNotRead' Access errors.
86+
await event_source.response.aread()
87+
raise e
88+
89+
# If the response is not a stream, read it standardly (e.g., upfront JSON-RPC error payload)
90+
if 'text/event-stream' not in event_source.response.headers.get(
91+
'content-type', ''
92+
):
93+
content = await event_source.response.aread()
94+
yield content.decode('utf-8')
95+
return
96+
8297
async for sse in event_source.aiter_sse():
8398
if not sse.data:
8499
continue

src/a2a/compat/v0_3/grpc_handler.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from a2a.server.request_handlers.request_handler import RequestHandler
3030
from a2a.types.a2a_pb2 import AgentCard
3131
from a2a.utils.errors import A2AError, InvalidParamsError
32-
from a2a.utils.helpers import maybe_await, validate, validate_async_generator
32+
from a2a.utils.helpers import maybe_await, validate
3333

3434

3535
logger = logging.getLogger(__name__)
@@ -170,17 +170,17 @@ async def _handler(
170170
context, _handler, a2a_v0_3_pb2.SendMessageResponse()
171171
)
172172

173-
@validate_async_generator(
174-
lambda self: self.agent_card.capabilities.streaming,
175-
'Streaming is not supported by the agent',
176-
)
177173
async def SendStreamingMessage(
178174
self,
179175
request: a2a_v0_3_pb2.SendMessageRequest,
180176
context: grpc.aio.ServicerContext,
181177
) -> AsyncIterable[a2a_v0_3_pb2.StreamResponse]:
182178
"""Handles the 'SendStreamingMessage' gRPC method (v0.3)."""
183179

180+
@validate(
181+
lambda _: self.agent_card.capabilities.streaming,
182+
'Streaming is not supported by the agent',
183+
)
184184
async def _handler(
185185
server_context: ServerCallContext,
186186
) -> AsyncIterable[a2a_v0_3_pb2.StreamResponse]:
@@ -233,17 +233,17 @@ async def _handler(
233233

234234
return await self._handle_unary(context, _handler, a2a_v0_3_pb2.Task())
235235

236-
@validate_async_generator(
237-
lambda self: self.agent_card.capabilities.streaming,
238-
'Streaming is not supported by the agent',
239-
)
240236
async def TaskSubscription(
241237
self,
242238
request: a2a_v0_3_pb2.TaskSubscriptionRequest,
243239
context: grpc.aio.ServicerContext,
244240
) -> AsyncIterable[a2a_v0_3_pb2.StreamResponse]:
245241
"""Handles the 'TaskSubscription' gRPC method (v0.3)."""
246242

243+
@validate(
244+
lambda _: self.agent_card.capabilities.streaming,
245+
'Streaming is not supported by the agent',
246+
)
247247
async def _handler(
248248
server_context: ServerCallContext,
249249
) -> AsyncIterable[a2a_v0_3_pb2.StreamResponse]:
@@ -260,17 +260,17 @@ async def _handler(
260260
async for item in self._handle_stream(context, _handler):
261261
yield item
262262

263-
@validate(
264-
lambda self: self.agent_card.capabilities.push_notifications,
265-
'Push notifications are not supported by the agent',
266-
)
267263
async def CreateTaskPushNotificationConfig(
268264
self,
269265
request: a2a_v0_3_pb2.CreateTaskPushNotificationConfigRequest,
270266
context: grpc.aio.ServicerContext,
271267
) -> a2a_v0_3_pb2.TaskPushNotificationConfig:
272268
"""Handles the 'CreateTaskPushNotificationConfig' gRPC method (v0.3)."""
273269

270+
@validate(
271+
lambda _: self.agent_card.capabilities.push_notifications,
272+
'Push notifications are not supported by the agent',
273+
)
274274
async def _handler(
275275
server_context: ServerCallContext,
276276
) -> a2a_v0_3_pb2.TaskPushNotificationConfig:

src/a2a/compat/v0_3/rest_handler.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
from a2a.utils import constants
3232
from a2a.utils.helpers import (
3333
validate,
34-
validate_async_generator,
3534
validate_version,
3635
)
3736
from a2a.utils.telemetry import SpanKind, trace_class
@@ -85,7 +84,7 @@ async def on_message_send(
8584
return MessageToDict(pb2_v03_resp)
8685

8786
@validate_version(constants.PROTOCOL_VERSION_0_3)
88-
@validate_async_generator(
87+
@validate(
8988
lambda self: self.agent_card.capabilities.streaming,
9089
'Streaming is not supported by the agent',
9190
)
@@ -143,7 +142,7 @@ async def on_cancel_task(
143142
return MessageToDict(pb2_v03_task)
144143

145144
@validate_version(constants.PROTOCOL_VERSION_0_3)
146-
@validate_async_generator(
145+
@validate(
147146
lambda self: self.agent_card.capabilities.streaming,
148147
'Streaming is not supported by the agent',
149148
)

src/a2a/server/events/event_consumer.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import asyncio
22
import logging
3-
import sys
43

54
from collections.abc import AsyncGenerator
65

76
from pydantic import ValidationError
87

9-
from a2a.server.events.event_queue import Event, EventQueue
8+
from a2a.server.events.event_queue import Event, EventQueue, QueueShutDown
109
from a2a.types.a2a_pb2 import (
1110
Message,
1211
Task,
@@ -17,13 +16,6 @@
1716
from a2a.utils.telemetry import SpanKind, trace_class
1817

1918

20-
# This is an alias to the exception for closed queue
21-
QueueClosed: type[Exception] = asyncio.QueueEmpty
22-
23-
# When using python 3.13 or higher, the closed queue signal is QueueShutdown
24-
if sys.version_info >= (3, 13):
25-
QueueClosed = asyncio.QueueShutDown
26-
2719
logger = logging.getLogger(__name__)
2820

2921

@@ -130,7 +122,7 @@ async def consume_all(self) -> AsyncGenerator[Event]:
130122
except asyncio.TimeoutError: # pyright: ignore [reportUnusedExcept]
131123
# This class was made an alias of built-in TimeoutError after 3.11
132124
continue
133-
except (QueueClosed, asyncio.QueueEmpty):
125+
except (QueueShutDown, asyncio.QueueEmpty):
134126
# Confirm that the queue is closed, e.g. we aren't on
135127
# python 3.12 and get a queue empty error on an open queue
136128
if self.queue.is_closed():

0 commit comments

Comments
 (0)