Skip to content

Commit 52a8fe4

Browse files
committed
refactor: simplify proto_utils metadata conversion and add utility functions
- Remove complex automatic type inference from translation layer - Replace custom _convert_value_to_proto/_convert_proto_to_value with simple dict_to_struct and json_format.MessageToDict - Add standalone utility functions for preprocessing/post-processing: - make_dict_serializable: converts non-serializable values to strings - normalize_large_integers_to_strings: handles JavaScript MAX_SAFE_INTEGER compatibility - parse_string_integers_in_dict: converts large integer strings back to integers - Update tests to use new utility functions and add comprehensive test coverage - Ensure bidirectional conversion consistency and user control over normalization This addresses concerns about automatic string-to-integer inference causing incompatibility between representations. The translation layer is now simple and predictable, with normalization being the responsibility of consumers.
1 parent eafdd42 commit 52a8fe4

File tree

2 files changed

+242
-91
lines changed

2 files changed

+242
-91
lines changed

src/a2a/utils/proto_utils.py

Lines changed: 86 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@
2323
r'tasks/([\w-]+)/pushNotificationConfigs/([\w-]+)'
2424
)
2525

26-
# Maximum safe integer digits (JavaScript MAX_SAFE_INTEGER is 2^53 - 1)
27-
_MAX_SAFE_INTEGER_DIGITS = 15
28-
2926

3027
class ToProto:
3128
"""Converts Python types to proto types."""
@@ -49,59 +46,7 @@ def metadata(
4946
) -> struct_pb2.Struct | None:
5047
if metadata is None:
5148
return None
52-
return struct_pb2.Struct(
53-
fields={
54-
key: cls._convert_value_to_proto(value)
55-
for key, value in metadata.items()
56-
}
57-
)
58-
59-
@classmethod
60-
def _make_dict_serializable(cls, value: Any) -> Any:
61-
if isinstance(value, dict):
62-
return {k: cls._make_dict_serializable(v) for k, v in value.items()}
63-
if isinstance(value, (list | tuple)):
64-
return [cls._make_dict_serializable(item) for item in value]
65-
if isinstance(value, (str | int | float | bool)) or value is None:
66-
return value
67-
return str(value)
68-
69-
@classmethod
70-
def _convert_value_to_proto(cls, value: Any) -> struct_pb2.Value:
71-
if value is None:
72-
proto_value = struct_pb2.Value()
73-
proto_value.null_value = struct_pb2.NullValue.NULL_VALUE
74-
return proto_value
75-
76-
if isinstance(value, bool):
77-
return struct_pb2.Value(bool_value=value)
78-
79-
if isinstance(value, int):
80-
if abs(value) > (2**53 - 1):
81-
return struct_pb2.Value(string_value=str(value))
82-
return struct_pb2.Value(number_value=float(value))
83-
84-
if isinstance(value, float):
85-
return struct_pb2.Value(number_value=value)
86-
87-
if isinstance(value, str):
88-
return struct_pb2.Value(string_value=value)
89-
90-
if isinstance(value, dict):
91-
serializable_dict = cls._make_dict_serializable(value)
92-
json_data = json.dumps(serializable_dict, ensure_ascii=False)
93-
struct_value = struct_pb2.Struct()
94-
json_format.Parse(json_data, struct_value)
95-
return struct_pb2.Value(struct_value=struct_value)
96-
97-
if isinstance(value, (list | tuple)):
98-
list_value = struct_pb2.ListValue()
99-
for item in value:
100-
converted_item = cls._convert_value_to_proto(item)
101-
list_value.values.append(converted_item)
102-
return struct_pb2.Value(list_value=list_value)
103-
104-
return struct_pb2.Value(string_value=str(value))
49+
return dict_to_struct(metadata)
10550

10651
@classmethod
10752
def part(cls, part: types.Part) -> a2a_pb2.Part:
@@ -542,37 +487,9 @@ def message(cls, message: a2a_pb2.Message) -> types.Message:
542487

543488
@classmethod
544489
def metadata(cls, metadata: struct_pb2.Struct) -> dict[str, Any]:
545-
return {
546-
key: cls._convert_proto_to_value(value)
547-
for key, value in metadata.fields.items()
548-
}
549-
550-
@classmethod
551-
def _convert_proto_to_value(cls, value: struct_pb2.Value) -> Any:
552-
if value.HasField('null_value'):
553-
return None
554-
if value.HasField('bool_value'):
555-
return value.bool_value
556-
if value.HasField('number_value'):
557-
return value.number_value
558-
if value.HasField('string_value'):
559-
string_val = value.string_value
560-
if (
561-
string_val.lstrip('-').isdigit()
562-
and len(string_val.lstrip('-')) > _MAX_SAFE_INTEGER_DIGITS
563-
):
564-
return int(string_val)
565-
return string_val
566-
if value.HasField('struct_value'):
567-
return {
568-
k: cls._convert_proto_to_value(v)
569-
for k, v in value.struct_value.fields.items()
570-
}
571-
if value.HasField('list_value'):
572-
return [
573-
cls._convert_proto_to_value(v) for v in value.list_value.values
574-
]
575-
return None
490+
if not metadata.fields:
491+
return {}
492+
return json_format.MessageToDict(metadata)
576493

577494
@classmethod
578495
def part(cls, part: a2a_pb2.Part) -> types.Part:
@@ -1044,3 +961,85 @@ def dict_to_struct(dictionary: dict[str, Any]) -> struct_pb2.Struct:
1044961
else:
1045962
struct[key] = val
1046963
return struct
964+
965+
966+
def make_dict_serializable(value: Any) -> Any:
967+
"""Dict preprocessing utility: converts non-serializable values to serializable form.
968+
969+
Use this when you want to normalize a dictionary before dict->Struct conversion.
970+
971+
Args:
972+
value: The value to convert.
973+
974+
Returns:
975+
A serializable value.
976+
"""
977+
if isinstance(value, dict):
978+
return {k: make_dict_serializable(v) for k, v in value.items()}
979+
if isinstance(value, list | tuple):
980+
return [make_dict_serializable(item) for item in value]
981+
if isinstance(value, str | int | float | bool) or value is None:
982+
return value
983+
return str(value)
984+
985+
986+
def normalize_large_integers_to_strings(
987+
value: Any, max_safe_digits: int = 15
988+
) -> Any:
989+
"""Integer preprocessing utility: converts large integers to strings.
990+
991+
Use this when you want to convert large integers to strings considering
992+
JavaScript's MAX_SAFE_INTEGER (2^53 - 1) limitation.
993+
994+
Args:
995+
value: The value to convert.
996+
max_safe_digits: Maximum safe integer digits (default: 15).
997+
998+
Returns:
999+
A normalized value.
1000+
"""
1001+
if isinstance(value, dict):
1002+
return {
1003+
k: normalize_large_integers_to_strings(v, max_safe_digits)
1004+
for k, v in value.items()
1005+
}
1006+
if isinstance(value, list | tuple):
1007+
return [
1008+
normalize_large_integers_to_strings(item, max_safe_digits)
1009+
for item in value
1010+
]
1011+
if isinstance(value, int) and abs(value) > (10**max_safe_digits - 1):
1012+
return str(value)
1013+
return value
1014+
1015+
1016+
def parse_string_integers_in_dict(value: Any, max_safe_digits: int = 15) -> Any:
1017+
"""String post-processing utility: converts large integer strings back to integers.
1018+
1019+
Use this when you want to restore large integer strings to integers
1020+
after Struct->dict conversion.
1021+
1022+
Args:
1023+
value: The value to convert.
1024+
max_safe_digits: Maximum safe integer digits (default: 15).
1025+
1026+
Returns:
1027+
A parsed value.
1028+
"""
1029+
if isinstance(value, dict):
1030+
return {
1031+
k: parse_string_integers_in_dict(v, max_safe_digits)
1032+
for k, v in value.items()
1033+
}
1034+
if isinstance(value, list | tuple):
1035+
return [
1036+
parse_string_integers_in_dict(item, max_safe_digits)
1037+
for item in value
1038+
]
1039+
if (
1040+
isinstance(value, str)
1041+
and value.lstrip('-').isdigit()
1042+
and len(value.lstrip('-')) > max_safe_digits
1043+
):
1044+
return int(value)
1045+
return value

tests/utils/test_proto_utils.py

Lines changed: 156 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ def test_metadata_conversion(self):
298298
assert roundtrip_metadata['complex_list'][0]['name'] == 'item1'
299299

300300
def test_metadata_with_custom_objects(self):
301-
"""Test metadata conversion with custom objects that need str() fallback."""
301+
"""Test metadata conversion with custom objects using preprocessing utility."""
302302

303303
class CustomObject:
304304
def __str__(self):
@@ -313,8 +313,11 @@ def __repr__(self):
313313
'nested_custom': {'obj': CustomObject(), 'normal': 'value'},
314314
}
315315

316+
# Use preprocessing utility to make it serializable
317+
serializable_metadata = proto_utils.make_dict_serializable(metadata)
318+
316319
# Convert to proto
317-
proto_metadata = proto_utils.ToProto.metadata(metadata)
320+
proto_metadata = proto_utils.ToProto.metadata(serializable_metadata)
318321
assert proto_metadata is not None
319322

320323
# Convert back to Python
@@ -339,7 +342,7 @@ def test_metadata_edge_cases(self):
339342
'false': False,
340343
'empty_string': '',
341344
'unicode_string': 'string test',
342-
'large_number': 9999999999999999,
345+
'safe_number': 9007199254740991, # JavaScript MAX_SAFE_INTEGER
343346
'negative_number': -42,
344347
'float_precision': 0.123456789,
345348
'numeric_string': '12345',
@@ -356,7 +359,156 @@ def test_metadata_edge_cases(self):
356359
assert roundtrip_metadata['false'] is False
357360
assert roundtrip_metadata['empty_string'] == ''
358361
assert roundtrip_metadata['unicode_string'] == 'string test'
359-
assert roundtrip_metadata['large_number'] == 9999999999999999
362+
assert roundtrip_metadata['safe_number'] == 9007199254740991
360363
assert roundtrip_metadata['negative_number'] == -42
361364
assert abs(roundtrip_metadata['float_precision'] - 0.123456789) < 1e-10
362365
assert roundtrip_metadata['numeric_string'] == '12345'
366+
367+
def test_make_dict_serializable(self):
368+
"""Test the make_dict_serializable utility function."""
369+
370+
class CustomObject:
371+
def __str__(self):
372+
return 'custom_str'
373+
374+
test_data = {
375+
'string': 'hello',
376+
'int': 42,
377+
'float': 3.14,
378+
'bool': True,
379+
'none': None,
380+
'custom': CustomObject(),
381+
'list': [1, 'two', CustomObject()],
382+
'tuple': (1, 2, CustomObject()),
383+
'nested': {'inner_custom': CustomObject(), 'inner_normal': 'value'},
384+
}
385+
386+
result = proto_utils.make_dict_serializable(test_data)
387+
388+
# Basic types should be unchanged
389+
assert result['string'] == 'hello'
390+
assert result['int'] == 42
391+
assert result['float'] == 3.14
392+
assert result['bool'] is True
393+
assert result['none'] is None
394+
395+
# Custom objects should be converted to strings
396+
assert result['custom'] == 'custom_str'
397+
assert result['list'] == [1, 'two', 'custom_str']
398+
assert result['tuple'] == [1, 2, 'custom_str'] # tuples become lists
399+
assert result['nested']['inner_custom'] == 'custom_str'
400+
assert result['nested']['inner_normal'] == 'value'
401+
402+
def test_normalize_large_integers_to_strings(self):
403+
"""Test the normalize_large_integers_to_strings utility function."""
404+
405+
test_data = {
406+
'small_int': 42,
407+
'large_int': 9999999999999999999, # > 15 digits
408+
'negative_large': -9999999999999999999,
409+
'float': 3.14,
410+
'string': 'hello',
411+
'list': [123, 9999999999999999999, 'text'],
412+
'nested': {'inner_large': 9999999999999999999, 'inner_small': 100},
413+
}
414+
415+
result = proto_utils.normalize_large_integers_to_strings(test_data)
416+
417+
# Small integers should remain as integers
418+
assert result['small_int'] == 42
419+
assert isinstance(result['small_int'], int)
420+
421+
# Large integers should be converted to strings
422+
assert result['large_int'] == '9999999999999999999'
423+
assert isinstance(result['large_int'], str)
424+
assert result['negative_large'] == '-9999999999999999999'
425+
assert isinstance(result['negative_large'], str)
426+
427+
# Other types should be unchanged
428+
assert result['float'] == 3.14
429+
assert result['string'] == 'hello'
430+
431+
# Lists should be processed recursively
432+
assert result['list'] == [123, '9999999999999999999', 'text']
433+
434+
# Nested dicts should be processed recursively
435+
assert result['nested']['inner_large'] == '9999999999999999999'
436+
assert result['nested']['inner_small'] == 100
437+
438+
def test_parse_string_integers_in_dict(self):
439+
"""Test the parse_string_integers_in_dict utility function."""
440+
441+
test_data = {
442+
'regular_string': 'hello',
443+
'numeric_string_small': '123', # small, should stay as string
444+
'numeric_string_large': '9999999999999999999', # > 15 digits, should become int
445+
'negative_large_string': '-9999999999999999999',
446+
'float_string': '3.14', # not all digits, should stay as string
447+
'mixed_string': '123abc', # not all digits, should stay as string
448+
'int': 42,
449+
'list': ['hello', '9999999999999999999', '123'],
450+
'nested': {
451+
'inner_large_string': '9999999999999999999',
452+
'inner_regular': 'value',
453+
},
454+
}
455+
456+
result = proto_utils.parse_string_integers_in_dict(test_data)
457+
458+
# Regular strings should remain unchanged
459+
assert result['regular_string'] == 'hello'
460+
assert (
461+
result['numeric_string_small'] == '123'
462+
) # too small, stays string
463+
assert result['float_string'] == '3.14' # not all digits
464+
assert result['mixed_string'] == '123abc' # not all digits
465+
466+
# Large numeric strings should be converted to integers
467+
assert result['numeric_string_large'] == 9999999999999999999
468+
assert isinstance(result['numeric_string_large'], int)
469+
assert result['negative_large_string'] == -9999999999999999999
470+
assert isinstance(result['negative_large_string'], int)
471+
472+
# Other types should be unchanged
473+
assert result['int'] == 42
474+
475+
# Lists should be processed recursively
476+
assert result['list'] == ['hello', 9999999999999999999, '123']
477+
478+
# Nested dicts should be processed recursively
479+
assert result['nested']['inner_large_string'] == 9999999999999999999
480+
assert result['nested']['inner_regular'] == 'value'
481+
482+
def test_large_integer_roundtrip_with_utilities(self):
483+
"""Test large integer handling with preprocessing and post-processing utilities."""
484+
485+
original_data = {
486+
'large_int': 9999999999999999999,
487+
'small_int': 42,
488+
'nested': {'another_large': 12345678901234567890, 'normal': 'text'},
489+
}
490+
491+
# Step 1: Preprocess to convert large integers to strings
492+
preprocessed = proto_utils.normalize_large_integers_to_strings(
493+
original_data
494+
)
495+
496+
# Step 2: Convert to proto
497+
proto_metadata = proto_utils.ToProto.metadata(preprocessed)
498+
assert proto_metadata is not None
499+
500+
# Step 3: Convert back from proto
501+
dict_from_proto = proto_utils.FromProto.metadata(proto_metadata)
502+
503+
# Step 4: Post-process to convert large integer strings back to integers
504+
final_result = proto_utils.parse_string_integers_in_dict(
505+
dict_from_proto
506+
)
507+
508+
# Verify roundtrip preserved the original data
509+
assert final_result['large_int'] == 9999999999999999999
510+
assert isinstance(final_result['large_int'], int)
511+
assert final_result['small_int'] == 42
512+
assert final_result['nested']['another_large'] == 12345678901234567890
513+
assert isinstance(final_result['nested']['another_large'], int)
514+
assert final_result['nested']['normal'] == 'text'

0 commit comments

Comments
 (0)