Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
22b42d9
fix: metadata serialization for custom objects and large integers
youngchannelforyou Aug 21, 2025
49824b3
fix: Replace magic number 15 with constant _MAX_SAFE_INTEGER_DIGITS
youngchannelforyou Aug 21, 2025
b551530
refactor: Simplify metadata serialization by removing redundant JSON …
youngchannelforyou Aug 21, 2025
33318f5
fix: Handle JSON-unserializable objects in metadata conversion
youngchannelforyou Aug 21, 2025
6b3f04f
test: Add numeric string roundtrip test and fix mypy type error
youngchannelforyou Aug 21, 2025
e560f95
fix: remove comment
youngchannelforyou Aug 21, 2025
995d554
Merge branch 'main' into fix-proto-utils-metadata-serialization
holtskinner Aug 21, 2025
eafdd42
Merge branch 'main' into fix-proto-utils-metadata-serialization
youngchannelforyou Aug 23, 2025
52a8fe4
refactor: simplify proto_utils metadata conversion and add utility fu…
youngchannelforyou Aug 25, 2025
6df6f6f
Merge branch 'main' into fix-proto-utils-metadata-serialization
youngchannelforyou Aug 26, 2025
37eb3d0
Merge branch 'main' into fix-proto-utils-metadata-serialization
youngchannelforyou Aug 28, 2025
671aee0
Merge branch 'main' into fix-proto-utils-metadata-serialization
holtskinner Sep 3, 2025
ff153f4
Move standalone utility functions to top of file
holtskinner Sep 3, 2025
ea0d978
Ignore Deprecated lint rule UP38
holtskinner Sep 3, 2025
1c58507
Update src/a2a/utils/proto_utils.py
youngchannelforyou Sep 3, 2025
6c5a27d
Update src/a2a/utils/proto_utils.py
youngchannelforyou Sep 3, 2025
8c249a7
Update src/a2a/utils/proto_utils.py
youngchannelforyou Sep 3, 2025
e61374f
Merge branch 'fix-proto-utils-metadata-serialization' of https://gith…
holtskinner Sep 3, 2025
ef3b263
Merge branch 'main' into fix-proto-utils-metadata-serialization
youngchannelforyou Sep 4, 2025
a258007
Merge branch 'main' into fix-proto-utils-metadata-serialization
holtskinner Sep 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ ignore = [
"TRY003",
"TRY201",
"FIX002",
"UP038",
]

select = [
Expand Down
80 changes: 80 additions & 0 deletions src/a2a/utils/proto_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,86 @@ def dict_to_struct(dictionary: dict[str, Any]) -> struct_pb2.Struct:
return struct


def make_dict_serializable(value: Any) -> Any:
"""Dict pre-processing utility: converts non-serializable values to serializable form.
Use this when you want to normalize a dictionary before dict->Struct conversion.
Args:
value: The value to convert.
Returns:
A serializable value.
"""
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, dict):
return {k: make_dict_serializable(v) for k, v in value.items()}
if isinstance(value, list | tuple):
return [make_dict_serializable(item) for item in value]
return str(value)


def normalize_large_integers_to_strings(
value: Any, max_safe_digits: int = 15
) -> Any:
"""Integer preprocessing utility: converts large integers to strings.
Use this when you want to convert large integers to strings considering
JavaScript's MAX_SAFE_INTEGER (2^53 - 1) limitation.
Args:
value: The value to convert.
max_safe_digits: Maximum safe integer digits (default: 15).
Returns:
A normalized value.
"""
max_safe_int = 10**max_safe_digits - 1

def _normalize(item: Any) -> Any:
if isinstance(item, int) and abs(item) > max_safe_int:
return str(item)
if isinstance(item, dict):
return {k: _normalize(v) for k, v in item.items()}
if isinstance(item, list | tuple):
return [_normalize(i) for i in item]
return item

return _normalize(value)


def parse_string_integers_in_dict(value: Any, max_safe_digits: int = 15) -> Any:
"""String post-processing utility: converts large integer strings back to integers.
Use this when you want to restore large integer strings to integers
after Struct->dict conversion.
Args:
value: The value to convert.
max_safe_digits: Maximum safe integer digits (default: 15).
Returns:
A parsed value.
"""
if isinstance(value, dict):
return {
k: parse_string_integers_in_dict(v, max_safe_digits)
for k, v in value.items()
}
if isinstance(value, list | tuple):
return [
parse_string_integers_in_dict(item, max_safe_digits)
for item in value
]
if isinstance(value, str):
# Handle potential negative numbers.
stripped_value = value.lstrip('-')
if stripped_value.isdigit() and len(stripped_value) > max_safe_digits:
return int(value)
return value


class ToProto:
"""Converts Python types to proto types."""

Expand Down
257 changes: 257 additions & 0 deletions tests/utils/test_proto_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,260 @@ def test_none_handling(self):
assert proto_utils.ToProto.provider(None) is None
assert proto_utils.ToProto.security(None) is None
assert proto_utils.ToProto.security_schemes(None) is None

def test_metadata_conversion(self):
"""Test metadata conversion with various data types."""
metadata = {
'null_value': None,
'bool_value': True,
'int_value': 42,
'float_value': 3.14,
'string_value': 'hello',
'dict_value': {'nested': 'dict', 'count': 10},
'list_value': [1, 'two', 3.0, True, None],
'tuple_value': (1, 2, 3),
'complex_list': [
{'name': 'item1', 'values': [1, 2, 3]},
{'name': 'item2', 'values': [4, 5, 6]},
],
}

# Convert to proto
proto_metadata = proto_utils.ToProto.metadata(metadata)
assert proto_metadata is not None

# Convert back to Python
roundtrip_metadata = proto_utils.FromProto.metadata(proto_metadata)

# Verify all values are preserved correctly
assert roundtrip_metadata['null_value'] is None
assert roundtrip_metadata['bool_value'] is True
assert roundtrip_metadata['int_value'] == 42
assert roundtrip_metadata['float_value'] == 3.14
assert roundtrip_metadata['string_value'] == 'hello'
assert roundtrip_metadata['dict_value']['nested'] == 'dict'
assert roundtrip_metadata['dict_value']['count'] == 10
assert roundtrip_metadata['list_value'] == [1, 'two', 3.0, True, None]
assert roundtrip_metadata['tuple_value'] == [
1,
2,
3,
] # tuples become lists
assert len(roundtrip_metadata['complex_list']) == 2
assert roundtrip_metadata['complex_list'][0]['name'] == 'item1'

def test_metadata_with_custom_objects(self):
"""Test metadata conversion with custom objects using preprocessing utility."""

class CustomObject:
def __str__(self):
return 'custom_object_str'

def __repr__(self):
return 'CustomObject()'

metadata = {
'custom_obj': CustomObject(),
'list_with_custom': [1, CustomObject(), 'text'],
'nested_custom': {'obj': CustomObject(), 'normal': 'value'},
}

# Use preprocessing utility to make it serializable
serializable_metadata = proto_utils.make_dict_serializable(metadata)

# Convert to proto
proto_metadata = proto_utils.ToProto.metadata(serializable_metadata)
assert proto_metadata is not None

# Convert back to Python
roundtrip_metadata = proto_utils.FromProto.metadata(proto_metadata)

# Custom objects should be converted to strings
assert roundtrip_metadata['custom_obj'] == 'custom_object_str'
assert roundtrip_metadata['list_with_custom'] == [
1,
'custom_object_str',
'text',
]
assert roundtrip_metadata['nested_custom']['obj'] == 'custom_object_str'
assert roundtrip_metadata['nested_custom']['normal'] == 'value'

def test_metadata_edge_cases(self):
"""Test metadata conversion with edge cases."""
metadata = {
'empty_dict': {},
'empty_list': [],
'zero': 0,
'false': False,
'empty_string': '',
'unicode_string': 'string test',
'safe_number': 9007199254740991, # JavaScript MAX_SAFE_INTEGER
'negative_number': -42,
'float_precision': 0.123456789,
'numeric_string': '12345',
}

# Convert to proto and back
proto_metadata = proto_utils.ToProto.metadata(metadata)
roundtrip_metadata = proto_utils.FromProto.metadata(proto_metadata)

# Verify edge cases are handled correctly
assert roundtrip_metadata['empty_dict'] == {}
assert roundtrip_metadata['empty_list'] == []
assert roundtrip_metadata['zero'] == 0
assert roundtrip_metadata['false'] is False
assert roundtrip_metadata['empty_string'] == ''
assert roundtrip_metadata['unicode_string'] == 'string test'
assert roundtrip_metadata['safe_number'] == 9007199254740991
assert roundtrip_metadata['negative_number'] == -42
assert abs(roundtrip_metadata['float_precision'] - 0.123456789) < 1e-10
assert roundtrip_metadata['numeric_string'] == '12345'

def test_make_dict_serializable(self):
"""Test the make_dict_serializable utility function."""

class CustomObject:
def __str__(self):
return 'custom_str'

test_data = {
'string': 'hello',
'int': 42,
'float': 3.14,
'bool': True,
'none': None,
'custom': CustomObject(),
'list': [1, 'two', CustomObject()],
'tuple': (1, 2, CustomObject()),
'nested': {'inner_custom': CustomObject(), 'inner_normal': 'value'},
}

result = proto_utils.make_dict_serializable(test_data)

# Basic types should be unchanged
assert result['string'] == 'hello'
assert result['int'] == 42
assert result['float'] == 3.14
assert result['bool'] is True
assert result['none'] is None

# Custom objects should be converted to strings
assert result['custom'] == 'custom_str'
assert result['list'] == [1, 'two', 'custom_str']
assert result['tuple'] == [1, 2, 'custom_str'] # tuples become lists
assert result['nested']['inner_custom'] == 'custom_str'
assert result['nested']['inner_normal'] == 'value'

def test_normalize_large_integers_to_strings(self):
"""Test the normalize_large_integers_to_strings utility function."""

test_data = {
'small_int': 42,
'large_int': 9999999999999999999, # > 15 digits
'negative_large': -9999999999999999999,
'float': 3.14,
'string': 'hello',
'list': [123, 9999999999999999999, 'text'],
'nested': {'inner_large': 9999999999999999999, 'inner_small': 100},
}

result = proto_utils.normalize_large_integers_to_strings(test_data)

# Small integers should remain as integers
assert result['small_int'] == 42
assert isinstance(result['small_int'], int)

# Large integers should be converted to strings
assert result['large_int'] == '9999999999999999999'
assert isinstance(result['large_int'], str)
assert result['negative_large'] == '-9999999999999999999'
assert isinstance(result['negative_large'], str)

# Other types should be unchanged
assert result['float'] == 3.14
assert result['string'] == 'hello'

# Lists should be processed recursively
assert result['list'] == [123, '9999999999999999999', 'text']

# Nested dicts should be processed recursively
assert result['nested']['inner_large'] == '9999999999999999999'
assert result['nested']['inner_small'] == 100

def test_parse_string_integers_in_dict(self):
"""Test the parse_string_integers_in_dict utility function."""

test_data = {
'regular_string': 'hello',
'numeric_string_small': '123', # small, should stay as string
'numeric_string_large': '9999999999999999999', # > 15 digits, should become int
'negative_large_string': '-9999999999999999999',
'float_string': '3.14', # not all digits, should stay as string
'mixed_string': '123abc', # not all digits, should stay as string
'int': 42,
'list': ['hello', '9999999999999999999', '123'],
'nested': {
'inner_large_string': '9999999999999999999',
'inner_regular': 'value',
},
}

result = proto_utils.parse_string_integers_in_dict(test_data)

# Regular strings should remain unchanged
assert result['regular_string'] == 'hello'
assert (
result['numeric_string_small'] == '123'
) # too small, stays string
assert result['float_string'] == '3.14' # not all digits
assert result['mixed_string'] == '123abc' # not all digits

# Large numeric strings should be converted to integers
assert result['numeric_string_large'] == 9999999999999999999
assert isinstance(result['numeric_string_large'], int)
assert result['negative_large_string'] == -9999999999999999999
assert isinstance(result['negative_large_string'], int)

# Other types should be unchanged
assert result['int'] == 42

# Lists should be processed recursively
assert result['list'] == ['hello', 9999999999999999999, '123']

# Nested dicts should be processed recursively
assert result['nested']['inner_large_string'] == 9999999999999999999
assert result['nested']['inner_regular'] == 'value'

def test_large_integer_roundtrip_with_utilities(self):
"""Test large integer handling with preprocessing and post-processing utilities."""

original_data = {
'large_int': 9999999999999999999,
'small_int': 42,
'nested': {'another_large': 12345678901234567890, 'normal': 'text'},
}

# Step 1: Preprocess to convert large integers to strings
preprocessed = proto_utils.normalize_large_integers_to_strings(
original_data
)

# Step 2: Convert to proto
proto_metadata = proto_utils.ToProto.metadata(preprocessed)
assert proto_metadata is not None

# Step 3: Convert back from proto
dict_from_proto = proto_utils.FromProto.metadata(proto_metadata)

# Step 4: Post-process to convert large integer strings back to integers
final_result = proto_utils.parse_string_integers_in_dict(
dict_from_proto
)

# Verify roundtrip preserved the original data
assert final_result['large_int'] == 9999999999999999999
assert isinstance(final_result['large_int'], int)
assert final_result['small_int'] == 42
assert final_result['nested']['another_large'] == 12345678901234567890
assert isinstance(final_result['nested']['another_large'], int)
assert final_result['nested']['normal'] == 'text'
Loading