Skip to content

Commit cfdbe4c

Browse files
feat(helpers): add non-text Part, Message, and Artifact helpers (#1004)
## Description - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [ ] Appropriate docs were updated (if necessary) --- ### Summary `proto_helpers.py` provided `new_text_message` and `new_text_artifact` for the text Part variant, but nothing for the three remaining Part types (`data`, `raw`, `url`). This PR completes the set. **The `data` case is especially awkward without a helper.** `Part.data` is `google.protobuf.Value` in the v1.0 spec, which requires a non-obvious `ParseDict` dance to construct from a plain Python value: ```python # Without helper from google.protobuf.json_format import ParseDict from google.protobuf import struct_pb2 part = Part(data=ParseDict({"answer": "hello"}, struct_pb2.Value())) # With helper part = new_data_part({"answer": "hello"}) ``` ### New helpers **Part primitives** (building blocks, mirror the existing implicit `Part(text=...)` pattern): | Helper | Part field | Accepts | |---|---|---| | `new_data_part(data)` | `data` (`google.protobuf.Value`) | Any JSON-serializable value (dict, list, str, …) | | `new_raw_part(raw, media_type, filename)` | `raw` (`bytes`) | Raw bytes with optional MIME type and filename | | `new_url_part(url, media_type, filename)` | `url` (`str`) | URL with optional MIME type and filename | **Message helpers** (mirror `new_text_message`): | Helper | Wraps | |---|---| | `new_data_message(data, role, context_id, task_id)` | `new_data_part` | | `new_raw_message(raw, media_type, filename, role, context_id, task_id)` | `new_raw_part` | | `new_url_message(url, media_type, filename, role, context_id, task_id)` | `new_url_part` | **Artifact helpers** (mirror `new_text_artifact`): | Helper | Wraps | |---|---| | `new_data_artifact(name, data, description, artifact_id)` | `new_data_part` | | `new_raw_artifact(name, raw, media_type, filename, description, artifact_id)` | `new_raw_part` | | `new_url_artifact(name, url, media_type, filename, description, artifact_id)` | `new_url_part` | ### Changes - `src/a2a/helpers/proto_helpers.py` — 9 new helper functions - `tests/helpers/test_proto_helpers.py` — tests for all new helpers (35 total, all passing) ### Reviewer feedback addressed - `new_data_part` type hint broadened from `dict[str, Any]` to `Any`, since `google.protobuf.Value` accepts any JSON-serializable value, not just dicts. Added a list-value test to cover this. Release-as: 1.0.2 --------- Co-authored-by: Sampath Kumar <sam1990kumar@gmail.com>
1 parent d02ae4e commit cfdbe4c

2 files changed

Lines changed: 488 additions & 18 deletions

File tree

src/a2a/helpers/proto_helpers.py

Lines changed: 260 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import uuid
44

55
from collections.abc import Sequence
6+
from typing import Any
7+
8+
from google.protobuf import struct_pb2
9+
from google.protobuf.json_format import ParseDict
610

711
from a2a.types.a2a_pb2 import (
812
Artifact,
@@ -23,9 +27,9 @@
2327

2428
def new_message(
2529
parts: list[Part],
26-
role: Role = Role.ROLE_AGENT,
2730
context_id: str | None = None,
2831
task_id: str | None = None,
32+
role: Role = Role.ROLE_AGENT,
2933
) -> Message:
3034
"""Creates a new message containing a list of Parts."""
3135
return Message(
@@ -39,16 +43,17 @@ def new_message(
3943

4044
def new_text_message(
4145
text: str,
46+
media_type: str | None = None,
4247
context_id: str | None = None,
4348
task_id: str | None = None,
4449
role: Role = Role.ROLE_AGENT,
4550
) -> Message:
4651
"""Creates a new message containing a single text Part."""
4752
return new_message(
48-
parts=[Part(text=text)],
49-
role=role,
50-
task_id=task_id,
53+
parts=[new_text_part(text, media_type=media_type)],
5154
context_id=context_id,
55+
task_id=task_id,
56+
role=role,
5257
)
5358

5459

@@ -57,6 +62,91 @@ def get_message_text(message: Message, delimiter: str = '\n') -> str:
5762
return delimiter.join(get_text_parts(message.parts))
5863

5964

65+
def new_data_message(
66+
data: Any,
67+
media_type: str | None = None,
68+
context_id: str | None = None,
69+
task_id: str | None = None,
70+
role: Role = Role.ROLE_AGENT,
71+
) -> Message:
72+
"""Creates a new message containing a single data Part.
73+
74+
Args:
75+
data: JSON-serializable data to embed (dict, list, str, etc.).
76+
media_type: Optional MIME type of the part content (e.g., "text/plain", "application/json", "image/png").
77+
context_id: Optional context ID.
78+
task_id: Optional task ID.
79+
role: The role of the message sender (default: ROLE_AGENT).
80+
81+
Returns:
82+
A Message with a single data Part.
83+
"""
84+
return new_message(
85+
parts=[new_data_part(data, media_type=media_type)],
86+
context_id=context_id,
87+
task_id=task_id,
88+
role=role,
89+
)
90+
91+
92+
def new_raw_message( # noqa: PLR0913
93+
raw: bytes,
94+
media_type: str | None = None,
95+
filename: str | None = None,
96+
context_id: str | None = None,
97+
task_id: str | None = None,
98+
role: Role = Role.ROLE_AGENT,
99+
) -> Message:
100+
"""Creates a new message containing a single raw bytes Part.
101+
102+
Args:
103+
raw: The raw bytes content.
104+
media_type: Optional MIME type (e.g. 'image/png').
105+
filename: Optional filename.
106+
context_id: Optional context ID.
107+
task_id: Optional task ID.
108+
role: The role of the message sender (default: ROLE_AGENT).
109+
110+
Returns:
111+
A Message with a single raw Part.
112+
"""
113+
return new_message(
114+
parts=[new_raw_part(raw, media_type=media_type, filename=filename)],
115+
context_id=context_id,
116+
task_id=task_id,
117+
role=role,
118+
)
119+
120+
121+
def new_url_message( # noqa: PLR0913
122+
url: str,
123+
media_type: str | None = None,
124+
filename: str | None = None,
125+
context_id: str | None = None,
126+
task_id: str | None = None,
127+
role: Role = Role.ROLE_AGENT,
128+
) -> Message:
129+
"""Creates a new message containing a single URL Part.
130+
131+
Args:
132+
url: The URL pointing to the file content.
133+
media_type: Optional MIME type (e.g. 'image/png').
134+
filename: Optional filename.
135+
context_id: Optional context ID.
136+
task_id: Optional task ID.
137+
role: The role of the message sender (default: ROLE_AGENT).
138+
139+
Returns:
140+
A Message with a single URL Part.
141+
"""
142+
return new_message(
143+
parts=[new_url_part(url, media_type=media_type, filename=filename)],
144+
context_id=context_id,
145+
task_id=task_id,
146+
role=role,
147+
)
148+
149+
60150
# --- Artifact Helpers ---
61151

62152

@@ -78,12 +168,98 @@ def new_artifact(
78168
def new_text_artifact(
79169
name: str,
80170
text: str,
171+
media_type: str | None = None,
81172
description: str | None = None,
82173
artifact_id: str | None = None,
83174
) -> Artifact:
84175
"""Creates a new Artifact object containing only a single text Part."""
85176
return new_artifact(
86-
[Part(text=text)],
177+
[new_text_part(text, media_type=media_type)],
178+
name,
179+
description,
180+
artifact_id=artifact_id,
181+
)
182+
183+
184+
def new_data_artifact(
185+
name: str,
186+
data: Any,
187+
media_type: str | None = None,
188+
description: str | None = None,
189+
artifact_id: str | None = None,
190+
) -> Artifact:
191+
"""Creates a new Artifact object containing only a single data Part.
192+
193+
Args:
194+
name: The name of the artifact.
195+
data: JSON-serializable data to embed (dict, list, str, etc.).
196+
media_type: Optional MIME type of the part content (e.g., "text/plain", "application/json", "image/png").
197+
description: Optional description.
198+
artifact_id: Optional artifact ID (auto-generated if not provided).
199+
200+
Returns:
201+
An Artifact with a single data Part.
202+
"""
203+
return new_artifact(
204+
[new_data_part(data, media_type=media_type)],
205+
name,
206+
description,
207+
artifact_id=artifact_id,
208+
)
209+
210+
211+
def new_raw_artifact( # noqa: PLR0913
212+
name: str,
213+
raw: bytes,
214+
media_type: str | None = None,
215+
filename: str | None = None,
216+
description: str | None = None,
217+
artifact_id: str | None = None,
218+
) -> Artifact:
219+
"""Creates a new Artifact object containing only a single raw bytes Part.
220+
221+
Args:
222+
name: The name of the artifact.
223+
raw: The raw bytes content.
224+
media_type: Optional MIME type (e.g. 'image/png').
225+
filename: Optional filename.
226+
description: Optional description.
227+
artifact_id: Optional artifact ID (auto-generated if not provided).
228+
229+
Returns:
230+
An Artifact with a single raw Part.
231+
"""
232+
return new_artifact(
233+
[new_raw_part(raw, media_type=media_type, filename=filename)],
234+
name,
235+
description,
236+
artifact_id=artifact_id,
237+
)
238+
239+
240+
def new_url_artifact( # noqa: PLR0913
241+
name: str,
242+
url: str,
243+
media_type: str | None = None,
244+
filename: str | None = None,
245+
description: str | None = None,
246+
artifact_id: str | None = None,
247+
) -> Artifact:
248+
"""Creates a new Artifact object containing only a single URL Part.
249+
250+
Args:
251+
name: The name of the artifact.
252+
url: The URL pointing to the file content.
253+
media_type: Optional MIME type (e.g. 'image/png').
254+
filename: Optional filename.
255+
description: Optional description.
256+
artifact_id: Optional artifact ID (auto-generated if not provided).
257+
258+
Returns:
259+
An Artifact with a single URL Part.
260+
"""
261+
return new_artifact(
262+
[new_url_part(url, media_type=media_type, filename=filename)],
87263
name,
88264
description,
89265
artifact_id=artifact_id,
@@ -141,6 +317,85 @@ def new_task(
141317
# --- Part Helpers ---
142318

143319

320+
def new_text_part(
321+
text: str,
322+
media_type: str | None = None,
323+
) -> Part:
324+
"""Creates a Part with text content.
325+
326+
Args:
327+
text: The text content.
328+
media_type: Optional MIME type (e.g. 'text/plain', 'text/markdown').
329+
330+
Returns:
331+
A Part with the text field set.
332+
"""
333+
return Part(text=text, media_type=media_type or '')
334+
335+
336+
def new_data_part(
337+
data: Any,
338+
media_type: str | None = None,
339+
) -> Part:
340+
"""Creates a Part with structured data (google.protobuf.Value).
341+
342+
Args:
343+
data: JSON-serializable data to embed (dict, list, str, etc.).
344+
media_type: Optional MIME type of the part content (e.g., "text/plain", "application/json", "image/png").
345+
346+
Returns:
347+
A Part with the data field set.
348+
"""
349+
return Part(
350+
data=ParseDict(data, struct_pb2.Value()),
351+
media_type=media_type or '',
352+
)
353+
354+
355+
def new_raw_part(
356+
raw: bytes,
357+
media_type: str | None = None,
358+
filename: str | None = None,
359+
) -> Part:
360+
"""Creates a Part with raw bytes content.
361+
362+
Args:
363+
raw: The raw bytes content.
364+
media_type: Optional MIME type (e.g. 'image/png').
365+
filename: Optional filename.
366+
367+
Returns:
368+
A Part with the raw field set.
369+
"""
370+
return Part(
371+
raw=raw,
372+
media_type=media_type or '',
373+
filename=filename or '',
374+
)
375+
376+
377+
def new_url_part(
378+
url: str,
379+
media_type: str | None = None,
380+
filename: str | None = None,
381+
) -> Part:
382+
"""Creates a Part with a URL pointing to file content.
383+
384+
Args:
385+
url: The URL to the file content.
386+
media_type: Optional MIME type (e.g. 'image/png').
387+
filename: Optional filename.
388+
389+
Returns:
390+
A Part with the url field set.
391+
"""
392+
return Part(
393+
url=url,
394+
media_type=media_type or '',
395+
filename=filename or '',
396+
)
397+
398+
144399
def get_text_parts(parts: Sequence[Part]) -> list[str]:
145400
"""Extracts text content from all text Parts."""
146401
return [part.text for part in parts if part.HasField('text')]

0 commit comments

Comments
 (0)