Skip to content

Commit c3e59e4

Browse files
committed
Make ProcessorPart inherit from genai.Part.
PiperOrigin-RevId: 821636805
1 parent aa497d2 commit c3e59e4

File tree

2 files changed

+49
-79
lines changed

2 files changed

+49
-79
lines changed

genai_processors/content_api.py

Lines changed: 42 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@
2626
from genai_processors import mime_types
2727
from google.genai import types as genai_types
2828
import PIL.Image
29+
import pydantic
2930

3031

31-
class ProcessorPart:
32+
class ProcessorPart(genai_types.Part):
3233
"""A wrapper around `Part` with additional metadata.
3334
3435
Represents a single piece of content that can be processed by an agentic
@@ -37,6 +38,10 @@ class ProcessorPart:
3738
Includes metadata such as the producer of the content, the substream the part
3839
belongs to, the MIME type of the content, and arbitrary metadata.
3940
"""
41+
_metadata: dict[str, Any] = pydantic.PrivateAttr(default_factory=dict)
42+
_role: str = pydantic.PrivateAttr(default='')
43+
_substream_name: str = pydantic.PrivateAttr(default='')
44+
_mimetype: str = pydantic.PrivateAttr(default='')
4045

4146
def __init__(
4247
self,
@@ -50,48 +55,43 @@ def __init__(
5055
"""Constructs a ProcessorPart.
5156
5257
Args:
53-
value: The content to use to construct the ProcessorPart.
58+
value: The content to use to construct the ProcessorPart. Any keyword
59+
arguments after this one overrides any properties in value.
5460
role: Optional. The producer of the content. In Genai models, must be
5561
either 'user' or 'model', but the user can set their own semantics.
5662
Useful to set for multi-turn conversations, otherwise can be empty.
5763
substream_name: (Optional) ProcessorPart stream can be split into multiple
5864
independent streams. They may have specific semantics, e.g. a song and
5965
its lyrics, or can be just alternative responses. Prefer using a default
60-
substream with an empty name. If the `ProcessorPart` is created using
61-
another `ProcessorPart`, this ProcessorPart inherits the existing
62-
substream_name, unless it is overridden in this argument.
66+
substream with an empty name.
6367
mimetype: Mime type of the data.
6468
metadata: (Optional) Auxiliary information about the part. If the
6569
`ProcessorPart` is created using another `ProcessorPart` or a
6670
`content_pb2.Part`, this ProcessorPart inherits the existing metadata,
6771
unless it is overridden in this argument.
6872
"""
69-
super().__init__()
70-
self._metadata = {}
71-
7273
match value:
73-
case genai_types.Part():
74-
self._part = value
7574
case ProcessorPart():
76-
self._part = value.part
75+
super().__init__(**value.model_dump(exclude_unset=True))
7776
role = role or value.role
7877
substream_name = substream_name or value.substream_name
7978
mimetype = mimetype or value.mimetype
8079
self._metadata = value.metadata
81-
self._metadata.update(metadata or {})
80+
case genai_types.Part():
81+
super().__init__(**value.model_dump(exclude_unset=True))
8282
case str():
83-
self._part = genai_types.Part(text=value)
83+
super().__init__(text=value)
8484
case bytes():
8585
if not mimetype:
8686
raise ValueError(
8787
'MIME type must be specified when constructing a ProcessorPart'
8888
' from bytes.'
8989
)
9090
if is_text(mimetype):
91-
self._part = genai_types.Part(text=value.decode('utf-8'))
91+
super().__init__(text=value.decode('utf-8'))
9292
else:
93-
self._part = genai_types.Part.from_bytes(
94-
data=value, mime_type=mimetype
93+
super().__init__(
94+
inline_data=genai_types.Blob(data=value, mime_type=mimetype)
9595
)
9696
case PIL.Image.Image():
9797
if mimetype:
@@ -113,8 +113,10 @@ def __init__(
113113
mimetype = f'image/{suffix}'
114114
bytes_io = io.BytesIO()
115115
value.save(bytes_io, suffix.upper())
116-
self._part = genai_types.Part.from_bytes(
117-
data=bytes_io.getvalue(), mime_type=mimetype
116+
super().__init__(
117+
inline_data=genai_types.Blob(
118+
data=bytes_io.getvalue(), mime_type=mimetype
119+
)
118120
)
119121
case _:
120122
raise ValueError(f"Can't construct ProcessorPart from {type(value)}.")
@@ -127,10 +129,10 @@ def __init__(
127129
if mimetype:
128130
self._mimetype = mimetype
129131
# Otherwise, if MIME type is specified using inline data, use that.
130-
elif self._part.inline_data and self._part.inline_data.mime_type:
131-
self._mimetype = self._part.inline_data.mime_type
132+
elif self.inline_data and self.inline_data.mime_type:
133+
self._mimetype = self.inline_data.mime_type
132134
# Otherwise, if text is not empty, assume 'text/plain' MIME type.
133-
elif self._part.text:
135+
elif self.text:
134136
self._mimetype = 'text/plain'
135137
else:
136138
self._mimetype = ''
@@ -144,24 +146,23 @@ def __repr__(self) -> str:
144146
if self.role:
145147
optional_args += f', role={self.role!r}'
146148
return (
147-
f'ProcessorPart({self.part.to_json_dict()!r},'
149+
f'ProcessorPart({self.to_json_dict()!r},'
148150
f' mimetype={self.mimetype!r}{optional_args})'
149151
)
150152

151153
def __eq__(self, other: Any) -> bool:
152154
if not isinstance(other, ProcessorPart):
153155
return False
154-
return (
155-
self._part == other._part
156-
and self._role.lower() == other._role.lower()
157-
and self._substream_name.lower() == other._substream_name.lower()
158-
and self._metadata == other._metadata
159-
)
156+
return self.__dict__ == other.__dict__
160157

161158
@property
162159
def part(self) -> genai_types.Part:
163-
"""Returns the underlying Genai Part."""
164-
return self._part
160+
"""Returns the underlying Genai Part.
161+
162+
DEPRECATED: Use the ProcessorPart itself, it now inherits from genai.Part.
163+
This property is provided for backward compatibility reasons.
164+
"""
165+
return self
165166

166167
@property
167168
def role(self) -> str:
@@ -187,10 +188,10 @@ def bytes(self) -> bytes | None:
187188
Text encoded into bytes or bytes from inline data if the underlying part
188189
is a Blob.
189190
"""
190-
if self.part.text:
191+
if self.text:
191192
return self.text.encode()
192-
if isinstance(self.part.inline_data, genai_types.Blob):
193-
return self.part.inline_data.data
193+
if isinstance(self.inline_data, genai_types.Blob):
194+
return self.inline_data.data
194195
return None
195196

196197
@property
@@ -213,25 +214,6 @@ def mimetype(self) -> str:
213214
"""
214215
return self._mimetype or 'text/plain'
215216

216-
@property
217-
def text(self) -> str:
218-
"""Returns part text as string.
219-
220-
Returns:
221-
The text of the part.
222-
223-
Raises:
224-
ValueError if part has no text.
225-
"""
226-
if not mime_types.is_text(self.mimetype):
227-
raise ValueError('Part is not text.')
228-
return self.part.text or ''
229-
230-
@text.setter
231-
def text(self, value: str) -> None:
232-
"""Sets part to a text part."""
233-
self._part = genai_types.Part(text=value)
234-
235217
@property
236218
def metadata(self) -> dict[str, Any]:
237219
"""Returns metadata."""
@@ -246,16 +228,6 @@ def get_metadata(self, key: str, default=None) -> Any:
246228
"""Returns metadata for a given key."""
247229
return self._metadata.get(key, default)
248230

249-
@property
250-
def function_call(self) -> genai_types.FunctionCall | None:
251-
"""Returns function call."""
252-
return self.part.function_call
253-
254-
@property
255-
def function_response(self) -> genai_types.FunctionResponse | None:
256-
"""Returns function response."""
257-
return self.part.function_response
258-
259231
@property
260232
def tool_cancellation(self) -> str | None:
261233
"""Returns an id of a function call to be cancelled.
@@ -266,13 +238,13 @@ def tool_cancellation(self) -> str | None:
266238
The id of the function call to be cancelled or None if this part is not a
267239
tool cancellation from the model.
268240
"""
269-
if not self.part.function_response:
241+
if not self.function_response:
270242
return None
271-
if self.part.function_response.name != 'tool_cancellation':
243+
if self.function_response.name != 'tool_cancellation':
272244
return None
273-
if not self.part.function_response.response:
245+
if not self.function_response.response:
274246
return None
275-
return self.part.function_response.response.get('function_call_id', None)
247+
return self.function_response.response.get('function_call_id', None)
276248

277249
T = TypeVar('T')
278250

@@ -301,8 +273,8 @@ def pil_image(self) -> PIL.Image.Image:
301273
if not mime_types.is_image(self.mimetype):
302274
raise ValueError(f'Part is not an image. Mime type is {self.mimetype}.')
303275
bytes_io = io.BytesIO()
304-
if self.part.inline_data is not None:
305-
bytes_io.write(self.part.inline_data.data)
276+
if self.inline_data is not None:
277+
bytes_io.write(self.inline_data.data)
306278
bytes_io.seek(0)
307279
return PIL.Image.open(bytes_io)
308280

@@ -474,7 +446,7 @@ def to_dict(self) -> dict[str, Any]:
474446
```
475447
"""
476448
return {
477-
'part': self.part.model_dump(mode='json', exclude_none=True),
449+
'part': self.model_dump(mode='json', exclude_none=True),
478450
'role': self.role,
479451
'substream_name': self.substream_name,
480452
'mimetype': self.mimetype,
@@ -848,10 +820,9 @@ def to_genai_contents(
848820
"""
849821
processor_content = ProcessorContent(content)
850822
contents = []
851-
for role, content_part in itertools.groupby(
823+
for role, content_parts in itertools.groupby(
852824
processor_content, lambda p: p.role
853825
):
854-
content_parts = [p.part for p in content_part]
855826
contents.append(genai_types.Content(parts=content_parts, role=role))
856827

857828
return contents

genai_processors/tests/content_api_test.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,14 @@ def test_from_bytes_with_text_mimetype(self):
7272
mimetype = 'text/plain'
7373
part = content_api.ProcessorPart(bytes_data, mimetype=mimetype)
7474
self.assertEqual(part.text, 'hello')
75-
self.assertEqual(part.part.text, 'hello')
7675

7776
def test_from_bytes_with_non_text_mimetype(self):
7877
bytes_data = b'hello'
7978
mimetype = 'application/octet-stream'
8079
part = content_api.ProcessorPart(bytes_data, mimetype=mimetype)
8180
self.assertEqual(part.bytes, bytes_data)
82-
self.assertEqual(part.part.inline_data.data, bytes_data)
83-
self.assertEqual(part.part.inline_data.mime_type, mimetype)
81+
self.assertEqual(part.inline_data.data, bytes_data)
82+
self.assertEqual(part.inline_data.mime_type, mimetype)
8483

8584
def test_eq_part_and_non_part(self):
8685
part = content_api.ProcessorPart('foo')
@@ -417,17 +416,17 @@ def test_to_genai_contents(self):
417416
expected_genai_contents = [
418417
genai_types.Content(
419418
parts=[
420-
genai_types.Part(text='part1'),
421-
genai_types.Part(text='part2'),
419+
content_api.ProcessorPart('part1'),
420+
content_api.ProcessorPart('part2'),
422421
],
423422
role='user',
424423
),
425424
genai_types.Content(
426-
parts=[genai_types.Part(text='part3')],
425+
parts=[content_api.ProcessorPart('part3')],
427426
role='model',
428427
),
429428
genai_types.Content(
430-
parts=[genai_types.Part(text='part4')],
429+
parts=[content_api.ProcessorPart('part4')],
431430
role='user',
432431
),
433432
]
@@ -443,7 +442,7 @@ def test_to_genai_contents_single_part(self):
443442
genai_contents = content_api.to_genai_contents(parts)
444443
expected_genai_contents = [
445444
genai_types.Content(
446-
parts=[genai_types.Part(text='part1')],
445+
parts=[content_api.ProcessorPart('part1')],
447446
role='user',
448447
),
449448
]

0 commit comments

Comments
 (0)