Skip to content

Commit 0dda885

Browse files
committed
Merge remote-tracking branch 'origin/main' into ishymko/merge-main
2 parents 639a663 + d2d3860 commit 0dda885

4 files changed

Lines changed: 164 additions & 15 deletions

File tree

CHANGELOG.md

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

3+
## [0.3.24](https://github.com/a2aproject/a2a-python/compare/v0.3.23...v0.3.24) (2026-02-20)
4+
5+
6+
### Bug Fixes
7+
8+
* **core:** preserve legitimate falsy values in _clean_empty ([#713](https://github.com/a2aproject/a2a-python/issues/713)) ([7632f55](https://github.com/a2aproject/a2a-python/commit/7632f55572641d8fbc353ee08ef2b1f6b75c38b6))
9+
* **deps:** `DeprecationWarning` on `HTTP_413_REQUEST_ENTITY_TOO_LARGE` ([#693](https://github.com/a2aproject/a2a-python/issues/693)) ([9968f9c](https://github.com/a2aproject/a2a-python/commit/9968f9c07f105bae8a6b296aeb6dea873b3b88b0))
10+
11+
## [0.3.23](https://github.com/a2aproject/a2a-python/compare/v0.3.22...v0.3.23) (2026-02-13)
12+
13+
14+
### Features
15+
16+
* add async context manager support to BaseClient ([#688](https://github.com/a2aproject/a2a-python/issues/688)) ([ae9dc88](https://github.com/a2aproject/a2a-python/commit/ae9dc8897885ad26461083682dd7ba008d5af3cb))
17+
* add async context manager support to ClientTransport ([#682](https://github.com/a2aproject/a2a-python/issues/682)) ([2e45c0d](https://github.com/a2aproject/a2a-python/commit/2e45c0d54e47f1725b13c67c8e509b0e6e61efb6))
18+
* support async card modifiers ([#654](https://github.com/a2aproject/a2a-python/issues/654)) ([a802500](https://github.com/a2aproject/a2a-python/commit/a802500b3ad82845c1a6fc155f80e75a20a1bcab))
19+
* support disabling OTel instrumentation via env var ([#611](https://github.com/a2aproject/a2a-python/issues/611)) ([72216b9](https://github.com/a2aproject/a2a-python/commit/72216b988c0681e07d26ea8d5489a619d1ad6dda))
20+
21+
22+
### Bug Fixes
23+
24+
* do not crash on SSE comment line ([#636](https://github.com/a2aproject/a2a-python/issues/636)) ([3dcb847](https://github.com/a2aproject/a2a-python/commit/3dcb84772fdc8a4d3b63b518ed491e5ed3d38d0a))
25+
* gRPC metadata header casing and invocation_metadata() call ([#676](https://github.com/a2aproject/a2a-python/issues/676)) ([390b763](https://github.com/a2aproject/a2a-python/commit/390b763d106eae3b2ca8ca78a2d0bfdc68f8fe2c))
26+
* Improve error handling for Timeout exceptions on REST and JSON-RPC clients ([#690](https://github.com/a2aproject/a2a-python/issues/690)) ([2acd838](https://github.com/a2aproject/a2a-python/commit/2acd838796d44ab9bfe6ba8c8b4ea0c2571a59dc))
27+
* map rejected task state in proto converters ([#668](https://github.com/a2aproject/a2a-python/issues/668)) ([957e92b](https://github.com/a2aproject/a2a-python/commit/957e92b9059792c44a40bbab18160996f5512145)), closes [#625](https://github.com/a2aproject/a2a-python/issues/625)
28+
* **server:** fix deadlocks on agent execution failure in non-streaming ([#614](https://github.com/a2aproject/a2a-python/issues/614)) ([d3c973f](https://github.com/a2aproject/a2a-python/commit/d3c973fe72afc0142f8a4c94d0c0fbe4ba2ddfe8))
29+
30+
31+
### Documentation
32+
33+
* explicitly mention supported spec version and transports in readme ([#681](https://github.com/a2aproject/a2a-python/issues/681)) ([c91d4fb](https://github.com/a2aproject/a2a-python/commit/c91d4fba517190d8f7c76b42ea26914a4275f1d5)), closes [#677](https://github.com/a2aproject/a2a-python/issues/677)
34+
* Update README to include Code Wiki badge ([2698cc0](https://github.com/a2aproject/a2a-python/commit/2698cc04f15282fb358018f06bd88ae159d987b4))
35+
336
## [0.3.22](https://github.com/a2aproject/a2a-python/compare/v0.3.21...v0.3.22) (2025-12-16)
437

538

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,14 @@
7070
from starlette.exceptions import HTTPException
7171
from starlette.requests import Request
7272
from starlette.responses import JSONResponse, Response
73-
from starlette.status import HTTP_413_REQUEST_ENTITY_TOO_LARGE
73+
74+
try:
75+
# Starlette v0.48.0
76+
from starlette.status import HTTP_413_CONTENT_TOO_LARGE
77+
except ImportError:
78+
from starlette.status import ( # type: ignore[no-redef]
79+
HTTP_413_REQUEST_ENTITY_TOO_LARGE as HTTP_413_CONTENT_TOO_LARGE,
80+
)
7481

7582
_package_starlette_installed = True
7683
else:
@@ -82,7 +89,14 @@
8289
from starlette.exceptions import HTTPException
8390
from starlette.requests import Request
8491
from starlette.responses import JSONResponse, Response
85-
from starlette.status import HTTP_413_REQUEST_ENTITY_TOO_LARGE
92+
93+
try:
94+
# Starlette v0.48.0
95+
from starlette.status import HTTP_413_CONTENT_TOO_LARGE
96+
except ImportError:
97+
from starlette.status import (
98+
HTTP_413_REQUEST_ENTITY_TOO_LARGE as HTTP_413_CONTENT_TOO_LARGE,
99+
)
86100

87101
_package_starlette_installed = True
88102
except ImportError:
@@ -96,7 +110,7 @@
96110
Request = Any
97111
JSONResponse = Any
98112
Response = Any
99-
HTTP_413_REQUEST_ENTITY_TOO_LARGE = Any
113+
HTTP_413_CONTENT_TOO_LARGE = Any
100114

101115

102116
class StarletteUserProxy(A2AUser):
@@ -394,7 +408,7 @@ async def _handle_requests(self, request: Request) -> Response: # noqa: PLR0911
394408
None, JSONParseError(message=str(e))
395409
)
396410
except HTTPException as e:
397-
if e.status_code == HTTP_413_REQUEST_ENTITY_TOO_LARGE:
411+
if e.status_code == HTTP_413_CONTENT_TOO_LARGE:
398412
return self._generate_error_response(
399413
request_id,
400414
InvalidRequestError(message='Payload too large'),

src/a2a/utils/helpers.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -350,14 +350,20 @@ def are_modalities_compatible(
350350
def _clean_empty(d: Any) -> Any:
351351
"""Recursively remove empty strings, lists and dicts from a dictionary."""
352352
if isinstance(d, dict):
353-
cleaned_dict: dict[Any, Any] = {
354-
k: _clean_empty(v) for k, v in d.items()
353+
cleaned_dict = {
354+
k: cleaned_v
355+
for k, v in d.items()
356+
if (cleaned_v := _clean_empty(v)) is not None
355357
}
356-
return {k: v for k, v in cleaned_dict.items() if v}
358+
return cleaned_dict or None
357359
if isinstance(d, list):
358-
cleaned_list: list[Any] = [_clean_empty(v) for v in d]
359-
return [v for v in cleaned_list if v]
360-
return d if d not in ['', [], {}] else None
360+
cleaned_list = [
361+
cleaned_v for v in d if (cleaned_v := _clean_empty(v)) is not None
362+
]
363+
return cleaned_list or None
364+
if isinstance(d, str) and not d:
365+
return None
366+
return d
361367

362368

363369
def canonicalize_agent_card(agent_card: AgentCard) -> str:

tests/utils/test_helpers.py

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import uuid
2+
23
from typing import Any
34
from unittest.mock import patch
45

56
import pytest
6-
from google.protobuf.json_format import MessageToDict
77

8-
from a2a.types.a2a_pb2 import (
9-
Artifact,
8+
from a2a.types import (
9+
AgentCapabilities,
1010
AgentCard,
1111
AgentCardSignature,
12-
AgentCapabilities,
1312
AgentInterface,
1413
AgentSkill,
14+
Artifact,
1515
Message,
1616
Part,
1717
Role,
@@ -23,12 +23,13 @@
2323
)
2424
from a2a.utils.errors import ServerError
2525
from a2a.utils.helpers import (
26+
_clean_empty,
2627
append_artifact_to_task,
2728
are_modalities_compatible,
2829
build_text_artifact,
30+
canonicalize_agent_card,
2931
create_task_obj,
3032
validate,
31-
canonicalize_agent_card,
3233
)
3334

3435

@@ -392,3 +393,98 @@ def test_canonicalize_agent_card():
392393
)
393394
result = canonicalize_agent_card(agent_card)
394395
assert result == expected_jcs
396+
397+
398+
def test_canonicalize_agent_card_preserves_false_capability():
399+
"""Regression #692: streaming=False must not be stripped from canonical JSON."""
400+
card = AgentCard(
401+
**{
402+
**SAMPLE_AGENT_CARD,
403+
'capabilities': AgentCapabilities(
404+
streaming=False,
405+
push_notifications=True,
406+
),
407+
}
408+
)
409+
result = canonicalize_agent_card(card)
410+
assert '"streaming":false' in result
411+
412+
413+
@pytest.mark.parametrize(
414+
'input_val',
415+
[
416+
pytest.param({'a': ''}, id='empty-string'),
417+
pytest.param({'a': []}, id='empty-list'),
418+
pytest.param({'a': {}}, id='empty-dict'),
419+
pytest.param({'a': {'b': []}}, id='nested-empty'),
420+
pytest.param({'a': '', 'b': [], 'c': {}}, id='all-empties'),
421+
pytest.param({'a': {'b': {'c': ''}}}, id='deeply-nested'),
422+
],
423+
)
424+
def test_clean_empty_removes_empties(input_val):
425+
"""_clean_empty removes empty strings, lists, and dicts recursively."""
426+
assert _clean_empty(input_val) is None
427+
428+
429+
def test_clean_empty_top_level_list_becomes_none():
430+
"""Top-level list that becomes empty after cleaning should return None."""
431+
assert _clean_empty(['', {}, []]) is None
432+
433+
434+
@pytest.mark.parametrize(
435+
'input_val,expected',
436+
[
437+
pytest.param({'retries': 0}, {'retries': 0}, id='int-zero'),
438+
pytest.param({'enabled': False}, {'enabled': False}, id='bool-false'),
439+
pytest.param({'score': 0.0}, {'score': 0.0}, id='float-zero'),
440+
pytest.param([0, 1, 2], [0, 1, 2], id='zero-in-list'),
441+
pytest.param([False, True], [False, True], id='false-in-list'),
442+
pytest.param(
443+
{'config': {'max_retries': 0, 'name': 'agent'}},
444+
{'config': {'max_retries': 0, 'name': 'agent'}},
445+
id='nested-zero',
446+
),
447+
],
448+
)
449+
def test_clean_empty_preserves_falsy_values(input_val, expected):
450+
"""_clean_empty preserves legitimate falsy values (0, False, 0.0)."""
451+
assert _clean_empty(input_val) == expected
452+
453+
454+
@pytest.mark.parametrize(
455+
'input_val,expected',
456+
[
457+
pytest.param(
458+
{'count': 0, 'label': '', 'items': []},
459+
{'count': 0},
460+
id='falsy-with-empties',
461+
),
462+
pytest.param(
463+
{'a': 0, 'b': 'hello', 'c': False, 'd': ''},
464+
{'a': 0, 'b': 'hello', 'c': False},
465+
id='mixed-types',
466+
),
467+
pytest.param(
468+
{'name': 'agent', 'retries': 0, 'tags': [], 'desc': ''},
469+
{'name': 'agent', 'retries': 0},
470+
id='realistic-mixed',
471+
),
472+
],
473+
)
474+
def test_clean_empty_mixed(input_val, expected):
475+
"""_clean_empty handles mixed empty and falsy values correctly."""
476+
assert _clean_empty(input_val) == expected
477+
478+
479+
def test_clean_empty_does_not_mutate_input():
480+
"""_clean_empty should not mutate the original input object."""
481+
original = {'a': '', 'b': 1, 'c': {'d': ''}}
482+
original_copy = {
483+
'a': '',
484+
'b': 1,
485+
'c': {'d': ''},
486+
}
487+
488+
_clean_empty(original)
489+
490+
assert original == original_copy

0 commit comments

Comments
 (0)