Skip to content

Commit 80fc33a

Browse files
feat: Add proto conversion utilities (#420)
- Adds `make_dict_serializable` to prepare dictionaries for proto conversion. - Adds `normalize_large_integers_to_strings` to convert large integers to strings, preventing precision loss in JS clients. - Adds `parse_string_integers_in_dict` to convert the integer strings back to `int`. - Includes comprehensive unit tests. --------- Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Co-authored-by: Holt Skinner <holtskinner@google.com>
1 parent d62df7a commit 80fc33a

File tree

3 files changed

+338
-0
lines changed

3 files changed

+338
-0
lines changed

.ruff.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ ignore = [
3232
"TRY003",
3333
"TRY201",
3434
"FIX002",
35+
"UP038",
3536
]
3637

3738
select = [

src/a2a/utils/proto_utils.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,86 @@ def dict_to_struct(dictionary: dict[str, Any]) -> struct_pb2.Struct:
4646
return struct
4747

4848

49+
def make_dict_serializable(value: Any) -> Any:
50+
"""Dict pre-processing utility: converts non-serializable values to serializable form.
51+
52+
Use this when you want to normalize a dictionary before dict->Struct conversion.
53+
54+
Args:
55+
value: The value to convert.
56+
57+
Returns:
58+
A serializable value.
59+
"""
60+
if isinstance(value, (str, int, float, bool)) or value is None:
61+
return value
62+
if isinstance(value, dict):
63+
return {k: make_dict_serializable(v) for k, v in value.items()}
64+
if isinstance(value, list | tuple):
65+
return [make_dict_serializable(item) for item in value]
66+
return str(value)
67+
68+
69+
def normalize_large_integers_to_strings(
70+
value: Any, max_safe_digits: int = 15
71+
) -> Any:
72+
"""Integer preprocessing utility: converts large integers to strings.
73+
74+
Use this when you want to convert large integers to strings considering
75+
JavaScript's MAX_SAFE_INTEGER (2^53 - 1) limitation.
76+
77+
Args:
78+
value: The value to convert.
79+
max_safe_digits: Maximum safe integer digits (default: 15).
80+
81+
Returns:
82+
A normalized value.
83+
"""
84+
max_safe_int = 10**max_safe_digits - 1
85+
86+
def _normalize(item: Any) -> Any:
87+
if isinstance(item, int) and abs(item) > max_safe_int:
88+
return str(item)
89+
if isinstance(item, dict):
90+
return {k: _normalize(v) for k, v in item.items()}
91+
if isinstance(item, list | tuple):
92+
return [_normalize(i) for i in item]
93+
return item
94+
95+
return _normalize(value)
96+
97+
98+
def parse_string_integers_in_dict(value: Any, max_safe_digits: int = 15) -> Any:
99+
"""String post-processing utility: converts large integer strings back to integers.
100+
101+
Use this when you want to restore large integer strings to integers
102+
after Struct->dict conversion.
103+
104+
Args:
105+
value: The value to convert.
106+
max_safe_digits: Maximum safe integer digits (default: 15).
107+
108+
Returns:
109+
A parsed value.
110+
"""
111+
if isinstance(value, dict):
112+
return {
113+
k: parse_string_integers_in_dict(v, max_safe_digits)
114+
for k, v in value.items()
115+
}
116+
if isinstance(value, list | tuple):
117+
return [
118+
parse_string_integers_in_dict(item, max_safe_digits)
119+
for item in value
120+
]
121+
if isinstance(value, str):
122+
# Handle potential negative numbers.
123+
stripped_value = value.lstrip('-')
124+
if stripped_value.isdigit() and len(stripped_value) > max_safe_digits:
125+
return int(value)
126+
return value
127+
128+
49129
class ToProto:
50130
"""Converts Python types to proto types."""
51131

tests/utils/test_proto_utils.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,260 @@ def test_none_handling(self):
251251
assert proto_utils.ToProto.provider(None) is None
252252
assert proto_utils.ToProto.security(None) is None
253253
assert proto_utils.ToProto.security_schemes(None) is None
254+
255+
def test_metadata_conversion(self):
256+
"""Test metadata conversion with various data types."""
257+
metadata = {
258+
'null_value': None,
259+
'bool_value': True,
260+
'int_value': 42,
261+
'float_value': 3.14,
262+
'string_value': 'hello',
263+
'dict_value': {'nested': 'dict', 'count': 10},
264+
'list_value': [1, 'two', 3.0, True, None],
265+
'tuple_value': (1, 2, 3),
266+
'complex_list': [
267+
{'name': 'item1', 'values': [1, 2, 3]},
268+
{'name': 'item2', 'values': [4, 5, 6]},
269+
],
270+
}
271+
272+
# Convert to proto
273+
proto_metadata = proto_utils.ToProto.metadata(metadata)
274+
assert proto_metadata is not None
275+
276+
# Convert back to Python
277+
roundtrip_metadata = proto_utils.FromProto.metadata(proto_metadata)
278+
279+
# Verify all values are preserved correctly
280+
assert roundtrip_metadata['null_value'] is None
281+
assert roundtrip_metadata['bool_value'] is True
282+
assert roundtrip_metadata['int_value'] == 42
283+
assert roundtrip_metadata['float_value'] == 3.14
284+
assert roundtrip_metadata['string_value'] == 'hello'
285+
assert roundtrip_metadata['dict_value']['nested'] == 'dict'
286+
assert roundtrip_metadata['dict_value']['count'] == 10
287+
assert roundtrip_metadata['list_value'] == [1, 'two', 3.0, True, None]
288+
assert roundtrip_metadata['tuple_value'] == [
289+
1,
290+
2,
291+
3,
292+
] # tuples become lists
293+
assert len(roundtrip_metadata['complex_list']) == 2
294+
assert roundtrip_metadata['complex_list'][0]['name'] == 'item1'
295+
296+
def test_metadata_with_custom_objects(self):
297+
"""Test metadata conversion with custom objects using preprocessing utility."""
298+
299+
class CustomObject:
300+
def __str__(self):
301+
return 'custom_object_str'
302+
303+
def __repr__(self):
304+
return 'CustomObject()'
305+
306+
metadata = {
307+
'custom_obj': CustomObject(),
308+
'list_with_custom': [1, CustomObject(), 'text'],
309+
'nested_custom': {'obj': CustomObject(), 'normal': 'value'},
310+
}
311+
312+
# Use preprocessing utility to make it serializable
313+
serializable_metadata = proto_utils.make_dict_serializable(metadata)
314+
315+
# Convert to proto
316+
proto_metadata = proto_utils.ToProto.metadata(serializable_metadata)
317+
assert proto_metadata is not None
318+
319+
# Convert back to Python
320+
roundtrip_metadata = proto_utils.FromProto.metadata(proto_metadata)
321+
322+
# Custom objects should be converted to strings
323+
assert roundtrip_metadata['custom_obj'] == 'custom_object_str'
324+
assert roundtrip_metadata['list_with_custom'] == [
325+
1,
326+
'custom_object_str',
327+
'text',
328+
]
329+
assert roundtrip_metadata['nested_custom']['obj'] == 'custom_object_str'
330+
assert roundtrip_metadata['nested_custom']['normal'] == 'value'
331+
332+
def test_metadata_edge_cases(self):
333+
"""Test metadata conversion with edge cases."""
334+
metadata = {
335+
'empty_dict': {},
336+
'empty_list': [],
337+
'zero': 0,
338+
'false': False,
339+
'empty_string': '',
340+
'unicode_string': 'string test',
341+
'safe_number': 9007199254740991, # JavaScript MAX_SAFE_INTEGER
342+
'negative_number': -42,
343+
'float_precision': 0.123456789,
344+
'numeric_string': '12345',
345+
}
346+
347+
# Convert to proto and back
348+
proto_metadata = proto_utils.ToProto.metadata(metadata)
349+
roundtrip_metadata = proto_utils.FromProto.metadata(proto_metadata)
350+
351+
# Verify edge cases are handled correctly
352+
assert roundtrip_metadata['empty_dict'] == {}
353+
assert roundtrip_metadata['empty_list'] == []
354+
assert roundtrip_metadata['zero'] == 0
355+
assert roundtrip_metadata['false'] is False
356+
assert roundtrip_metadata['empty_string'] == ''
357+
assert roundtrip_metadata['unicode_string'] == 'string test'
358+
assert roundtrip_metadata['safe_number'] == 9007199254740991
359+
assert roundtrip_metadata['negative_number'] == -42
360+
assert abs(roundtrip_metadata['float_precision'] - 0.123456789) < 1e-10
361+
assert roundtrip_metadata['numeric_string'] == '12345'
362+
363+
def test_make_dict_serializable(self):
364+
"""Test the make_dict_serializable utility function."""
365+
366+
class CustomObject:
367+
def __str__(self):
368+
return 'custom_str'
369+
370+
test_data = {
371+
'string': 'hello',
372+
'int': 42,
373+
'float': 3.14,
374+
'bool': True,
375+
'none': None,
376+
'custom': CustomObject(),
377+
'list': [1, 'two', CustomObject()],
378+
'tuple': (1, 2, CustomObject()),
379+
'nested': {'inner_custom': CustomObject(), 'inner_normal': 'value'},
380+
}
381+
382+
result = proto_utils.make_dict_serializable(test_data)
383+
384+
# Basic types should be unchanged
385+
assert result['string'] == 'hello'
386+
assert result['int'] == 42
387+
assert result['float'] == 3.14
388+
assert result['bool'] is True
389+
assert result['none'] is None
390+
391+
# Custom objects should be converted to strings
392+
assert result['custom'] == 'custom_str'
393+
assert result['list'] == [1, 'two', 'custom_str']
394+
assert result['tuple'] == [1, 2, 'custom_str'] # tuples become lists
395+
assert result['nested']['inner_custom'] == 'custom_str'
396+
assert result['nested']['inner_normal'] == 'value'
397+
398+
def test_normalize_large_integers_to_strings(self):
399+
"""Test the normalize_large_integers_to_strings utility function."""
400+
401+
test_data = {
402+
'small_int': 42,
403+
'large_int': 9999999999999999999, # > 15 digits
404+
'negative_large': -9999999999999999999,
405+
'float': 3.14,
406+
'string': 'hello',
407+
'list': [123, 9999999999999999999, 'text'],
408+
'nested': {'inner_large': 9999999999999999999, 'inner_small': 100},
409+
}
410+
411+
result = proto_utils.normalize_large_integers_to_strings(test_data)
412+
413+
# Small integers should remain as integers
414+
assert result['small_int'] == 42
415+
assert isinstance(result['small_int'], int)
416+
417+
# Large integers should be converted to strings
418+
assert result['large_int'] == '9999999999999999999'
419+
assert isinstance(result['large_int'], str)
420+
assert result['negative_large'] == '-9999999999999999999'
421+
assert isinstance(result['negative_large'], str)
422+
423+
# Other types should be unchanged
424+
assert result['float'] == 3.14
425+
assert result['string'] == 'hello'
426+
427+
# Lists should be processed recursively
428+
assert result['list'] == [123, '9999999999999999999', 'text']
429+
430+
# Nested dicts should be processed recursively
431+
assert result['nested']['inner_large'] == '9999999999999999999'
432+
assert result['nested']['inner_small'] == 100
433+
434+
def test_parse_string_integers_in_dict(self):
435+
"""Test the parse_string_integers_in_dict utility function."""
436+
437+
test_data = {
438+
'regular_string': 'hello',
439+
'numeric_string_small': '123', # small, should stay as string
440+
'numeric_string_large': '9999999999999999999', # > 15 digits, should become int
441+
'negative_large_string': '-9999999999999999999',
442+
'float_string': '3.14', # not all digits, should stay as string
443+
'mixed_string': '123abc', # not all digits, should stay as string
444+
'int': 42,
445+
'list': ['hello', '9999999999999999999', '123'],
446+
'nested': {
447+
'inner_large_string': '9999999999999999999',
448+
'inner_regular': 'value',
449+
},
450+
}
451+
452+
result = proto_utils.parse_string_integers_in_dict(test_data)
453+
454+
# Regular strings should remain unchanged
455+
assert result['regular_string'] == 'hello'
456+
assert (
457+
result['numeric_string_small'] == '123'
458+
) # too small, stays string
459+
assert result['float_string'] == '3.14' # not all digits
460+
assert result['mixed_string'] == '123abc' # not all digits
461+
462+
# Large numeric strings should be converted to integers
463+
assert result['numeric_string_large'] == 9999999999999999999
464+
assert isinstance(result['numeric_string_large'], int)
465+
assert result['negative_large_string'] == -9999999999999999999
466+
assert isinstance(result['negative_large_string'], int)
467+
468+
# Other types should be unchanged
469+
assert result['int'] == 42
470+
471+
# Lists should be processed recursively
472+
assert result['list'] == ['hello', 9999999999999999999, '123']
473+
474+
# Nested dicts should be processed recursively
475+
assert result['nested']['inner_large_string'] == 9999999999999999999
476+
assert result['nested']['inner_regular'] == 'value'
477+
478+
def test_large_integer_roundtrip_with_utilities(self):
479+
"""Test large integer handling with preprocessing and post-processing utilities."""
480+
481+
original_data = {
482+
'large_int': 9999999999999999999,
483+
'small_int': 42,
484+
'nested': {'another_large': 12345678901234567890, 'normal': 'text'},
485+
}
486+
487+
# Step 1: Preprocess to convert large integers to strings
488+
preprocessed = proto_utils.normalize_large_integers_to_strings(
489+
original_data
490+
)
491+
492+
# Step 2: Convert to proto
493+
proto_metadata = proto_utils.ToProto.metadata(preprocessed)
494+
assert proto_metadata is not None
495+
496+
# Step 3: Convert back from proto
497+
dict_from_proto = proto_utils.FromProto.metadata(proto_metadata)
498+
499+
# Step 4: Post-process to convert large integer strings back to integers
500+
final_result = proto_utils.parse_string_integers_in_dict(
501+
dict_from_proto
502+
)
503+
504+
# Verify roundtrip preserved the original data
505+
assert final_result['large_int'] == 9999999999999999999
506+
assert isinstance(final_result['large_int'], int)
507+
assert final_result['small_int'] == 42
508+
assert final_result['nested']['another_large'] == 12345678901234567890
509+
assert isinstance(final_result['nested']['another_large'], int)
510+
assert final_result['nested']['normal'] == 'text'

0 commit comments

Comments
 (0)