From 22b42d9d6627f26b7cb6846f4210e4acc04e1edc Mon Sep 17 00:00:00 2001 From: sven Date: Fri, 22 Aug 2025 01:43:39 +0900 Subject: [PATCH 01/12] fix: metadata serialization for custom objects and large integers - Fix JSON serialization error for custom objects in metadata by adding _make_json_serializable helper method that converts non-serializable objects to strings recursively - Fix precision loss for large integers (>2^53-1) by storing them as strings and converting back to int when deserializing - Update type hints to use union syntax instead of tuple for isinstance - All tests now pass including test_metadata_with_custom_objects and test_metadata_edge_cases --- src/a2a/utils/proto_utils.py | 72 ++++++++++++++++++++-- tests/utils/test_proto_utils.py | 103 ++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 5 deletions(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index e8f9e718..733cd6ce 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -47,14 +47,53 @@ def metadata( if metadata is None: return None return struct_pb2.Struct( - # TODO: Add support for other types. fields={ - key: struct_pb2.Value(string_value=value) + key: cls._convert_value_to_proto(value) for key, value in metadata.items() - if isinstance(value, str) } ) + @classmethod + def _convert_value_to_proto(cls, value: Any) -> struct_pb2.Value: + """Convert Python value to protobuf Value.""" + if value is None: + proto_value = struct_pb2.Value() + proto_value.null_value = 0 + return proto_value + if isinstance(value, bool): + return struct_pb2.Value(bool_value=value) + if isinstance(value, int): + if abs(value) > (2**53 - 1): + return struct_pb2.Value(string_value=str(value)) + return struct_pb2.Value(number_value=float(value)) + if isinstance(value, float): + return struct_pb2.Value(number_value=value) + if isinstance(value, str): + return struct_pb2.Value(string_value=value) + if isinstance(value, dict): + serializable_dict = cls._make_json_serializable(value) + json_data = json.dumps(serializable_dict, ensure_ascii=False) + struct_value = struct_pb2.Struct() + json_format.Parse(json_data, struct_value) + return struct_pb2.Value(struct_value=struct_value) + if isinstance(value, list | tuple): + list_value = struct_pb2.ListValue() + for item in value: + converted_item = cls._convert_value_to_proto(item) + list_value.values.append(converted_item) + return struct_pb2.Value(list_value=list_value) + return struct_pb2.Value(string_value=str(value)) + + @classmethod + def _make_json_serializable(cls, value: Any) -> Any: + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, dict): + return {k: cls._make_json_serializable(v) for k, v in value.items()} + if isinstance(value, list | tuple): + return [cls._make_json_serializable(item) for item in value] + return str(value) + @classmethod def part(cls, part: types.Part) -> a2a_pb2.Part: if isinstance(part.root, types.TextPart): @@ -478,11 +517,34 @@ def message(cls, message: a2a_pb2.Message) -> types.Message: @classmethod def metadata(cls, metadata: struct_pb2.Struct) -> dict[str, Any]: return { - key: value.string_value + key: cls._convert_proto_to_value(value) for key, value in metadata.fields.items() - if value.string_value } + @classmethod + def _convert_proto_to_value(cls, value: struct_pb2.Value) -> Any: + if value.HasField('null_value'): + return None + if value.HasField('bool_value'): + return value.bool_value + if value.HasField('number_value'): + return value.number_value + if value.HasField('string_value'): + string_val = value.string_value + if string_val.lstrip('-').isdigit(): + return int(string_val) + return string_val + if value.HasField('struct_value'): + return { + k: cls._convert_proto_to_value(v) + for k, v in value.struct_value.fields.items() + } + if value.HasField('list_value'): + return [ + cls._convert_proto_to_value(v) for v in value.list_value.values + ] + return None + @classmethod def part(cls, part: a2a_pb2.Part) -> types.Part: if part.HasField('text'): diff --git a/tests/utils/test_proto_utils.py b/tests/utils/test_proto_utils.py index c3f1b6a4..74d0aa99 100644 --- a/tests/utils/test_proto_utils.py +++ b/tests/utils/test_proto_utils.py @@ -255,3 +255,106 @@ 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 that need str() fallback.""" + + 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'}, + } + + # 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) + + # 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', + 'large_number': 9999999999999999, + 'negative_number': -42, + 'float_precision': 0.123456789, + } + + # 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['large_number'] == 9999999999999999 + assert roundtrip_metadata['negative_number'] == -42 + assert abs(roundtrip_metadata['float_precision'] - 0.123456789) < 1e-10 From 49824b3defad6c8cb03d146302b6884450c30884 Mon Sep 17 00:00:00 2001 From: sven Date: Fri, 22 Aug 2025 01:55:35 +0900 Subject: [PATCH 02/12] fix: Replace magic number 15 with constant _MAX_SAFE_INTEGER_DIGITS - Added _MAX_SAFE_INTEGER_DIGITS constant to improve code readability - Replaced hardcoded value 15 with the named constant in metadata conversion --- src/a2a/utils/proto_utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index 733cd6ce..4b356647 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -23,6 +23,9 @@ r'tasks/([\w-]+)/pushNotificationConfigs/([\w-]+)' ) +# Maximum safe integer digits (JavaScript MAX_SAFE_INTEGER is 2^53 - 1) +_MAX_SAFE_INTEGER_DIGITS = 15 + class ToProto: """Converts Python types to proto types.""" @@ -531,7 +534,10 @@ def _convert_proto_to_value(cls, value: struct_pb2.Value) -> Any: return value.number_value if value.HasField('string_value'): string_val = value.string_value - if string_val.lstrip('-').isdigit(): + if ( + string_val.lstrip('-').isdigit() + and len(string_val.lstrip('-')) > _MAX_SAFE_INTEGER_DIGITS + ): return int(string_val) return string_val if value.HasField('struct_value'): From b551530bfedea0cc2f24c3f5ee5a5ac3a28c1989 Mon Sep 17 00:00:00 2001 From: sven Date: Fri, 22 Aug 2025 01:59:00 +0900 Subject: [PATCH 03/12] refactor: Simplify metadata serialization by removing redundant JSON conversion - Remove _make_json_serializable method entirely - Refactor _convert_value_to_proto to handle dictionaries and lists recursively - Fix null value handling to use struct_pb2.NULL_VALUE - Improve code efficiency and maintainability - All tests passing --- src/a2a/utils/proto_utils.py | 37 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index 4b356647..81b2c724 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -60,9 +60,7 @@ def metadata( def _convert_value_to_proto(cls, value: Any) -> struct_pb2.Value: """Convert Python value to protobuf Value.""" if value is None: - proto_value = struct_pb2.Value() - proto_value.null_value = 0 - return proto_value + return struct_pb2.Value(null_value=struct_pb2.NULL_VALUE) if isinstance(value, bool): return struct_pb2.Value(bool_value=value) if isinstance(value, int): @@ -74,29 +72,22 @@ def _convert_value_to_proto(cls, value: Any) -> struct_pb2.Value: if isinstance(value, str): return struct_pb2.Value(string_value=value) if isinstance(value, dict): - serializable_dict = cls._make_json_serializable(value) - json_data = json.dumps(serializable_dict, ensure_ascii=False) - struct_value = struct_pb2.Struct() - json_format.Parse(json_data, struct_value) - return struct_pb2.Value(struct_value=struct_value) + return struct_pb2.Value( + struct_value=struct_pb2.Struct( + fields={ + k: cls._convert_value_to_proto(v) + for k, v in value.items() + } + ) + ) if isinstance(value, list | tuple): - list_value = struct_pb2.ListValue() - for item in value: - converted_item = cls._convert_value_to_proto(item) - list_value.values.append(converted_item) - return struct_pb2.Value(list_value=list_value) + return struct_pb2.Value( + list_value=struct_pb2.ListValue( + values=[cls._convert_value_to_proto(item) for item in value] + ) + ) return struct_pb2.Value(string_value=str(value)) - @classmethod - def _make_json_serializable(cls, value: Any) -> Any: - if value is None or isinstance(value, str | int | float | bool): - return value - if isinstance(value, dict): - return {k: cls._make_json_serializable(v) for k, v in value.items()} - if isinstance(value, list | tuple): - return [cls._make_json_serializable(item) for item in value] - return str(value) - @classmethod def part(cls, part: types.Part) -> a2a_pb2.Part: if isinstance(part.root, types.TextPart): From 33318f59c8d326dea511b9abc10b055d7d6a436c Mon Sep 17 00:00:00 2001 From: sven Date: Fri, 22 Aug 2025 02:32:15 +0900 Subject: [PATCH 04/12] fix: Handle JSON-unserializable objects in metadata conversion - Add _make_dict_serializable helper method to recursively convert dict values - Convert custom objects to strings using str() for JSON serialization - Fix test_metadata_with_custom_objects test failure - Improve code style with modern Python union syntax (| operator) --- .github/actions/spelling/allow.txt | 42 +++++++++++++------------- src/a2a/utils/proto_utils.py | 48 ++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 37 deletions(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 1216135c..b4f48fd4 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -1,16 +1,9 @@ +ACMRTUXB ACard AClient -ACMRTUXB -aconnect -adk AError AFast -agentic AGrpc -aio -aiomysql -amannn -aproject ARequest ARun AServer @@ -18,6 +11,26 @@ AServers AService AStarlette AUser +DSNs +EUR +GBP +GVsb +INR +JPY +JSONRPCt +JWS +Llm +POSTGRES +RUF +SLF +Tful +aconnect +adk +agentic +aio +aiomysql +amannn +aproject autouse backticks cla @@ -27,32 +40,23 @@ codegen coro datamodel drivername -DSNs dunders euo -EUR excinfo fernet fetchrow fetchval -GBP genai getkwargs gle -GVsb ietf initdb inmemory -INR isready -JPY -JSONRPCt -JWS kwarg langgraph lifecycles linting -Llm lstrips mikeas mockurl @@ -62,7 +66,6 @@ oidc opensource otherurl postgres -POSTGRES postgresql protoc pyi @@ -72,13 +75,10 @@ pyversions redef respx resub -RUF -SLF socio sse tagwords taskupdate testuuid -Tful typeerror vulnz diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index 81b2c724..27a88dc6 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -56,36 +56,52 @@ def metadata( } ) + @classmethod + def _make_dict_serializable(cls, value: Any) -> Any: + """재귀적으로 값을 JSON 직렬화 가능한 형태로 변환합니다.""" + if isinstance(value, dict): + return {k: cls._make_dict_serializable(v) for k, v in value.items()} + if isinstance(value, (list | tuple)): + return [cls._make_dict_serializable(item) for item in value] + if isinstance(value, (str | int | float | bool)) or value is None: + return value + return str(value) + @classmethod def _convert_value_to_proto(cls, value: Any) -> struct_pb2.Value: - """Convert Python value to protobuf Value.""" if value is None: - return struct_pb2.Value(null_value=struct_pb2.NULL_VALUE) + proto_value = struct_pb2.Value() + proto_value.null_value = 0 + return proto_value + if isinstance(value, bool): return struct_pb2.Value(bool_value=value) + if isinstance(value, int): if abs(value) > (2**53 - 1): return struct_pb2.Value(string_value=str(value)) return struct_pb2.Value(number_value=float(value)) + if isinstance(value, float): return struct_pb2.Value(number_value=value) + if isinstance(value, str): return struct_pb2.Value(string_value=value) + if isinstance(value, dict): - return struct_pb2.Value( - struct_value=struct_pb2.Struct( - fields={ - k: cls._convert_value_to_proto(v) - for k, v in value.items() - } - ) - ) - if isinstance(value, list | tuple): - return struct_pb2.Value( - list_value=struct_pb2.ListValue( - values=[cls._convert_value_to_proto(item) for item in value] - ) - ) + serializable_dict = cls._make_dict_serializable(value) + json_data = json.dumps(serializable_dict, ensure_ascii=False) + struct_value = struct_pb2.Struct() + json_format.Parse(json_data, struct_value) + return struct_pb2.Value(struct_value=struct_value) + + if isinstance(value, (list | tuple)): + list_value = struct_pb2.ListValue() + for item in value: + converted_item = cls._convert_value_to_proto(item) + list_value.values.append(converted_item) + return struct_pb2.Value(list_value=list_value) + return struct_pb2.Value(string_value=str(value)) @classmethod From 6b3f04f5d505f5da3fa2c88f0e9043f17da5565f Mon Sep 17 00:00:00 2001 From: sven Date: Fri, 22 Aug 2025 02:37:50 +0900 Subject: [PATCH 05/12] test: Add numeric string roundtrip test and fix mypy type error - Add test case for numeric strings (e.g., zip codes, numeric IDs) in metadata edge cases - Fix mypy type error in _convert_value_to_proto for null_value assignment - Use proper struct_pb2.NullValue.NULL_VALUE instead of integer 0 - Ensure numeric strings remain as strings after roundtrip conversion --- src/a2a/utils/proto_utils.py | 2 +- tests/utils/test_proto_utils.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index 27a88dc6..71b02812 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -71,7 +71,7 @@ def _make_dict_serializable(cls, value: Any) -> Any: def _convert_value_to_proto(cls, value: Any) -> struct_pb2.Value: if value is None: proto_value = struct_pb2.Value() - proto_value.null_value = 0 + proto_value.null_value = struct_pb2.NullValue.NULL_VALUE return proto_value if isinstance(value, bool): diff --git a/tests/utils/test_proto_utils.py b/tests/utils/test_proto_utils.py index 74d0aa99..0faef75f 100644 --- a/tests/utils/test_proto_utils.py +++ b/tests/utils/test_proto_utils.py @@ -342,6 +342,7 @@ def test_metadata_edge_cases(self): 'large_number': 9999999999999999, 'negative_number': -42, 'float_precision': 0.123456789, + 'numeric_string': '12345', } # Convert to proto and back @@ -358,3 +359,4 @@ def test_metadata_edge_cases(self): assert roundtrip_metadata['large_number'] == 9999999999999999 assert roundtrip_metadata['negative_number'] == -42 assert abs(roundtrip_metadata['float_precision'] - 0.123456789) < 1e-10 + assert roundtrip_metadata['numeric_string'] == '12345' From e560f957c669a6a7356bd0196dafa30f78d7ae62 Mon Sep 17 00:00:00 2001 From: sven Date: Fri, 22 Aug 2025 02:41:06 +0900 Subject: [PATCH 06/12] fix: remove comment --- src/a2a/utils/proto_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index 71b02812..8ae431f4 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -58,7 +58,6 @@ def metadata( @classmethod def _make_dict_serializable(cls, value: Any) -> Any: - """재귀적으로 값을 JSON 직렬화 가능한 형태로 변환합니다.""" if isinstance(value, dict): return {k: cls._make_dict_serializable(v) for k, v in value.items()} if isinstance(value, (list | tuple)): From 52a8fe4a9e2852daa0eb33c81ad0b7816ee64315 Mon Sep 17 00:00:00 2001 From: sven Date: Mon, 25 Aug 2025 13:34:34 +0900 Subject: [PATCH 07/12] 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. --- src/a2a/utils/proto_utils.py | 173 ++++++++++++++++---------------- tests/utils/test_proto_utils.py | 160 ++++++++++++++++++++++++++++- 2 files changed, 242 insertions(+), 91 deletions(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index b9429381..17913ce4 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -23,9 +23,6 @@ r'tasks/([\w-]+)/pushNotificationConfigs/([\w-]+)' ) -# Maximum safe integer digits (JavaScript MAX_SAFE_INTEGER is 2^53 - 1) -_MAX_SAFE_INTEGER_DIGITS = 15 - class ToProto: """Converts Python types to proto types.""" @@ -49,59 +46,7 @@ def metadata( ) -> struct_pb2.Struct | None: if metadata is None: return None - return struct_pb2.Struct( - fields={ - key: cls._convert_value_to_proto(value) - for key, value in metadata.items() - } - ) - - @classmethod - def _make_dict_serializable(cls, value: Any) -> Any: - if isinstance(value, dict): - return {k: cls._make_dict_serializable(v) for k, v in value.items()} - if isinstance(value, (list | tuple)): - return [cls._make_dict_serializable(item) for item in value] - if isinstance(value, (str | int | float | bool)) or value is None: - return value - return str(value) - - @classmethod - def _convert_value_to_proto(cls, value: Any) -> struct_pb2.Value: - if value is None: - proto_value = struct_pb2.Value() - proto_value.null_value = struct_pb2.NullValue.NULL_VALUE - return proto_value - - if isinstance(value, bool): - return struct_pb2.Value(bool_value=value) - - if isinstance(value, int): - if abs(value) > (2**53 - 1): - return struct_pb2.Value(string_value=str(value)) - return struct_pb2.Value(number_value=float(value)) - - if isinstance(value, float): - return struct_pb2.Value(number_value=value) - - if isinstance(value, str): - return struct_pb2.Value(string_value=value) - - if isinstance(value, dict): - serializable_dict = cls._make_dict_serializable(value) - json_data = json.dumps(serializable_dict, ensure_ascii=False) - struct_value = struct_pb2.Struct() - json_format.Parse(json_data, struct_value) - return struct_pb2.Value(struct_value=struct_value) - - if isinstance(value, (list | tuple)): - list_value = struct_pb2.ListValue() - for item in value: - converted_item = cls._convert_value_to_proto(item) - list_value.values.append(converted_item) - return struct_pb2.Value(list_value=list_value) - - return struct_pb2.Value(string_value=str(value)) + return dict_to_struct(metadata) @classmethod def part(cls, part: types.Part) -> a2a_pb2.Part: @@ -542,37 +487,9 @@ def message(cls, message: a2a_pb2.Message) -> types.Message: @classmethod def metadata(cls, metadata: struct_pb2.Struct) -> dict[str, Any]: - return { - key: cls._convert_proto_to_value(value) - for key, value in metadata.fields.items() - } - - @classmethod - def _convert_proto_to_value(cls, value: struct_pb2.Value) -> Any: - if value.HasField('null_value'): - return None - if value.HasField('bool_value'): - return value.bool_value - if value.HasField('number_value'): - return value.number_value - if value.HasField('string_value'): - string_val = value.string_value - if ( - string_val.lstrip('-').isdigit() - and len(string_val.lstrip('-')) > _MAX_SAFE_INTEGER_DIGITS - ): - return int(string_val) - return string_val - if value.HasField('struct_value'): - return { - k: cls._convert_proto_to_value(v) - for k, v in value.struct_value.fields.items() - } - if value.HasField('list_value'): - return [ - cls._convert_proto_to_value(v) for v in value.list_value.values - ] - return None + if not metadata.fields: + return {} + return json_format.MessageToDict(metadata) @classmethod def part(cls, part: a2a_pb2.Part) -> types.Part: @@ -1044,3 +961,85 @@ def dict_to_struct(dictionary: dict[str, Any]) -> struct_pb2.Struct: else: struct[key] = val return struct + + +def make_dict_serializable(value: Any) -> Any: + """Dict preprocessing 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, 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] + if isinstance(value, str | int | float | bool) or value is None: + return 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. + """ + if isinstance(value, dict): + return { + k: normalize_large_integers_to_strings(v, max_safe_digits) + for k, v in value.items() + } + if isinstance(value, list | tuple): + return [ + normalize_large_integers_to_strings(item, max_safe_digits) + for item in value + ] + if isinstance(value, int) and abs(value) > (10**max_safe_digits - 1): + return str(value) + return 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) + and value.lstrip('-').isdigit() + and len(value.lstrip('-')) > max_safe_digits + ): + return int(value) + return value diff --git a/tests/utils/test_proto_utils.py b/tests/utils/test_proto_utils.py index 0faef75f..f5ce8787 100644 --- a/tests/utils/test_proto_utils.py +++ b/tests/utils/test_proto_utils.py @@ -298,7 +298,7 @@ def test_metadata_conversion(self): assert roundtrip_metadata['complex_list'][0]['name'] == 'item1' def test_metadata_with_custom_objects(self): - """Test metadata conversion with custom objects that need str() fallback.""" + """Test metadata conversion with custom objects using preprocessing utility.""" class CustomObject: def __str__(self): @@ -313,8 +313,11 @@ def __repr__(self): '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(metadata) + proto_metadata = proto_utils.ToProto.metadata(serializable_metadata) assert proto_metadata is not None # Convert back to Python @@ -339,7 +342,7 @@ def test_metadata_edge_cases(self): 'false': False, 'empty_string': '', 'unicode_string': 'string test', - 'large_number': 9999999999999999, + 'safe_number': 9007199254740991, # JavaScript MAX_SAFE_INTEGER 'negative_number': -42, 'float_precision': 0.123456789, 'numeric_string': '12345', @@ -356,7 +359,156 @@ def test_metadata_edge_cases(self): assert roundtrip_metadata['false'] is False assert roundtrip_metadata['empty_string'] == '' assert roundtrip_metadata['unicode_string'] == 'string test' - assert roundtrip_metadata['large_number'] == 9999999999999999 + 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' From ff153f49913ba5688a5793fa47faa23f380c00d2 Mon Sep 17 00:00:00 2001 From: Holt Skinner Date: Wed, 3 Sep 2025 09:32:36 -0500 Subject: [PATCH 08/12] Move standalone utility functions to top of file --- .github/actions/spelling/allow.txt | 42 +++---- src/a2a/utils/proto_utils.py | 186 +++++++++++++---------------- 2 files changed, 103 insertions(+), 125 deletions(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index b4f48fd4..1216135c 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -1,9 +1,16 @@ -ACMRTUXB ACard AClient +ACMRTUXB +aconnect +adk AError AFast +agentic AGrpc +aio +aiomysql +amannn +aproject ARequest ARun AServer @@ -11,26 +18,6 @@ AServers AService AStarlette AUser -DSNs -EUR -GBP -GVsb -INR -JPY -JSONRPCt -JWS -Llm -POSTGRES -RUF -SLF -Tful -aconnect -adk -agentic -aio -aiomysql -amannn -aproject autouse backticks cla @@ -40,23 +27,32 @@ codegen coro datamodel drivername +DSNs dunders euo +EUR excinfo fernet fetchrow fetchval +GBP genai getkwargs gle +GVsb ietf initdb inmemory +INR isready +JPY +JSONRPCt +JWS kwarg langgraph lifecycles linting +Llm lstrips mikeas mockurl @@ -66,6 +62,7 @@ oidc opensource otherurl postgres +POSTGRES postgresql protoc pyi @@ -75,10 +72,13 @@ pyversions redef respx resub +RUF +SLF socio sse tagwords taskupdate testuuid +Tful typeerror vulnz diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index 4355664b..e064e4e9 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -46,6 +46,88 @@ 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, 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] + if isinstance(value, str | int | float | bool) or value is None: + return 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. + """ + if isinstance(value, dict): + return { + k: normalize_large_integers_to_strings(v, max_safe_digits) + for k, v in value.items() + } + if isinstance(value, list | tuple): + return [ + normalize_large_integers_to_strings(item, max_safe_digits) + for item in value + ] + if isinstance(value, int) and abs(value) > (10**max_safe_digits - 1): + return str(value) + return 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) + and value.lstrip('-').isdigit() + and len(value.lstrip('-')) > max_safe_digits + ): + return int(value) + return value + + class ToProto: """Converts Python types to proto types.""" @@ -980,107 +1062,3 @@ def role(cls, role: a2a_pb2.Role) -> types.Role: return types.Role.agent case _: return types.Role.agent - - -def dict_to_struct(dictionary: dict[str, Any]) -> struct_pb2.Struct: - """Converts a Python dict to a Struct proto. - - Unfortunately, using `json_format.ParseDict` does not work because this - wants the dictionary to be an exact match of the Struct proto with fields - and keys and values, not the traditional Python dict structure. - - Args: - dictionary: The Python dict to convert. - - Returns: - The Struct proto. - """ - struct = struct_pb2.Struct() - for key, val in dictionary.items(): - if isinstance(val, dict): - struct[key] = dict_to_struct(val) - else: - struct[key] = val - return struct - - -def make_dict_serializable(value: Any) -> Any: - """Dict preprocessing 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, 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] - if isinstance(value, str | int | float | bool) or value is None: - return 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. - """ - if isinstance(value, dict): - return { - k: normalize_large_integers_to_strings(v, max_safe_digits) - for k, v in value.items() - } - if isinstance(value, list | tuple): - return [ - normalize_large_integers_to_strings(item, max_safe_digits) - for item in value - ] - if isinstance(value, int) and abs(value) > (10**max_safe_digits - 1): - return str(value) - return 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) - and value.lstrip('-').isdigit() - and len(value.lstrip('-')) > max_safe_digits - ): - return int(value) - return value From ea0d978b09feadd840bfa1febb1a40e13b29c3ec Mon Sep 17 00:00:00 2001 From: Holt Skinner Date: Wed, 3 Sep 2025 09:38:49 -0500 Subject: [PATCH 09/12] Ignore Deprecated lint rule UP38 --- .ruff.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.ruff.toml b/.ruff.toml index 42a2340a..3562de26 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -32,6 +32,7 @@ ignore = [ "TRY003", "TRY201", "FIX002", + "UP038", ] select = [ From 1c585071b47deff32f1c86c9afb0116ab770a95b Mon Sep 17 00:00:00 2001 From: youngchannel Date: Thu, 4 Sep 2025 00:15:32 +0900 Subject: [PATCH 10/12] Update src/a2a/utils/proto_utils.py Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- src/a2a/utils/proto_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index e064e4e9..c3d4b3a6 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -57,12 +57,12 @@ def make_dict_serializable(value: Any) -> Any: 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] - if isinstance(value, str | int | float | bool) or value is None: - return value return str(value) From 6c5a27d2f53a6463008f5cec6e1c643e16cafa4e Mon Sep 17 00:00:00 2001 From: youngchannel Date: Thu, 4 Sep 2025 00:18:29 +0900 Subject: [PATCH 11/12] Update src/a2a/utils/proto_utils.py Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- src/a2a/utils/proto_utils.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index c3d4b3a6..826e1fe4 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -81,19 +81,18 @@ def normalize_large_integers_to_strings( Returns: A normalized value. """ - if isinstance(value, dict): - return { - k: normalize_large_integers_to_strings(v, max_safe_digits) - for k, v in value.items() - } - if isinstance(value, list | tuple): - return [ - normalize_large_integers_to_strings(item, max_safe_digits) - for item in value - ] - if isinstance(value, int) and abs(value) > (10**max_safe_digits - 1): - return str(value) - return 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: From 8c249a7799d724f5cdd2825b32571d0222666e7d Mon Sep 17 00:00:00 2001 From: youngchannel Date: Thu, 4 Sep 2025 00:20:21 +0900 Subject: [PATCH 12/12] Update src/a2a/utils/proto_utils.py Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> --- src/a2a/utils/proto_utils.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/a2a/utils/proto_utils.py b/src/a2a/utils/proto_utils.py index 826e1fe4..f75d06ab 100644 --- a/src/a2a/utils/proto_utils.py +++ b/src/a2a/utils/proto_utils.py @@ -118,12 +118,11 @@ def parse_string_integers_in_dict(value: Any, max_safe_digits: int = 15) -> Any: parse_string_integers_in_dict(item, max_safe_digits) for item in value ] - if ( - isinstance(value, str) - and value.lstrip('-').isdigit() - and len(value.lstrip('-')) > max_safe_digits - ): - return int(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