From 08ecf9f2818ff34778f5e6b34b77b689c9413be2 Mon Sep 17 00:00:00 2001 From: 56bad <56badkarma@gmail.com> Date: Fri, 17 Jul 2026 22:54:02 -0400 Subject: [PATCH] fix(json): only convert unambiguous date/datetime/UUID strings in additional data try_get_anything ran every untyped string through five lenient parsers (datetime, UUID, timedelta, date, time). On Python 3.11+ this mangled ordinary text values landing in additional_data: "19-2026" became time(19, 0) at offset -20:26, "PT" became timedelta(0), "11.0" became time(11, 0), and 32-char hex strings became UUIDs. Round-tripping such records then persisted the mangled strings, corrupting user data. Only convert strings matching an anchored full ISO date/datetime shape or the canonical hyphenated UUID form, mirroring the .NET JsonParseNode which attempts strict DateTime/DateTimeOffset/Guid parses only. Date-only strings still parse through datetime_from_iso_format_compat, preserving the existing datetime result for real payload values. The previous four-digit and isdigit special cases are no longer needed since all-digit strings cannot match either pattern. Fixes microsoft/kiota-python#487 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Uui4g7E7UmzKRGHMW5zbH --- .../json_parse_node.py | 49 ++++++++----------- .../json/tests/unit/test_json_parse_node.py | 41 ++++++++++++++++ 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/packages/serialization/json/kiota_serialization_json/json_parse_node.py b/packages/serialization/json/kiota_serialization_json/json_parse_node.py index 898b6d02..67ecbd4b 100644 --- a/packages/serialization/json/kiota_serialization_json/json_parse_node.py +++ b/packages/serialization/json/kiota_serialization_json/json_parse_node.py @@ -358,9 +358,17 @@ def _assign_field_values(self, item: Parsable) -> None: deserialize but the model doesn't support additional data" ) - def __is_four_digit_number(self, value: str) -> bool: - pattern = r'^\d{4}$' - return bool(re.match(pattern, value)) + # Anchored shapes that are unambiguous date/datetime/UUID literals. Untyped + # strings are only converted when they match one of these; anything else is + # returned as-is. Lenient guessing (time, timedelta, compact/partial dates) + # mangled real-world text values such as "19-2026" (parsed as a time with a + # UTC offset), "PT" (empty duration) or "11.0" (11:00). This mirrors the + # .NET JsonParseNode, which only attempts strict DateTime/DateTimeOffset/ + # Guid parses on strings. + _ISO_DATETIME_OR_DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}|$)') + _CANONICAL_UUID_PATTERN = re.compile( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + ) def try_get_anything(self, value: Any) -> Any: if isinstance(value, (int, float, bool)) or value is None: @@ -370,31 +378,16 @@ def try_get_anything(self, value: Any) -> Any: if isinstance(value, dict): return dict(map(lambda x: (x[0], self.try_get_anything(x[1])), value.items())) if isinstance(value, str): - try: - if self.__is_four_digit_number(value): - return value - if value.isdigit(): - return value - datetime_obj = datetime_from_iso_format_compat(value) - return datetime_obj - except ValueError: - pass - try: - return UUID(value) - except: - pass - try: - return parse_timedelta_string(value) - except ValueError: - pass - try: - return date.fromisoformat(value) - except ValueError: - pass - try: - return time_from_iso_format_compat(value) - except ValueError: - pass + if self._ISO_DATETIME_OR_DATE_PATTERN.match(value): + try: + return datetime_from_iso_format_compat(value) + except ValueError: + pass + elif self._CANONICAL_UUID_PATTERN.match(value): + try: + return UUID(value) + except ValueError: + pass return value raise ValueError(f"Unexpected additional value type {type(value)} during deserialization.") diff --git a/packages/serialization/json/tests/unit/test_json_parse_node.py b/packages/serialization/json/tests/unit/test_json_parse_node.py index 0b09ddc0..fc6dac21 100644 --- a/packages/serialization/json/tests/unit/test_json_parse_node.py +++ b/packages/serialization/json/tests/unit/test_json_parse_node.py @@ -228,6 +228,47 @@ def test_get_anythin_does_convert_date_string_to_datetime(): assert result == datetime(2023, 10, 5, 14, 48, tzinfo=timezone.utc) +@pytest.mark.parametrize( + "value", + [ + # microsoftgraph/msgraph-sdk-python#1340: text values coerced to + # datetime.time via lenient time.fromisoformat (3.11+ accepts an + # hour plus a UTC-offset-looking suffix or a fraction separator) + "19-2026", # invoice number, parsed as time(19, 0) at offset -20:26 + "100-012863299", # invoice number + "23.085", # parsed as time(23, 0, 0, 85000) + "11.0", # version string, parsed as time(11, 0) + # coerced to timedelta via parse_timedelta_string + "PT", # country code, parsed as an empty ISO 8601 duration + "10:30", # parsed as hh:mm + # microsoft/kiota-python#487: 32-hex string coerced to UUID + "d41d8cd98f00b204e9800998ecf8427e", + # date-like fragments must not be parsed as dates + "12-25", + "2023-10", + ], +) +def test_get_anything_does_not_coerce_text_values(value): + parse_node = JsonParseNode(value) + result = parse_node.try_get_anything(value) + assert isinstance(result, str) + assert result == value + + +def test_get_anything_converts_date_only_string_to_datetime(): + parse_node = JsonParseNode("2023-10-05") + result = parse_node.try_get_anything("2023-10-05") + assert isinstance(result, datetime) + assert result == datetime(2023, 10, 5, 0, 0) + + +def test_get_anything_converts_canonical_uuid_string(): + parse_node = JsonParseNode("8f841f30-e6e3-439a-a812-ebd369559c36") + result = parse_node.try_get_anything("8f841f30-e6e3-439a-a812-ebd369559c36") + assert isinstance(result, UUID) + assert result == UUID("8f841f30-e6e3-439a-a812-ebd369559c36") + + def test_get_object_value(user1_json): parse_node = JsonParseNode(json.loads(user1_json)) result = parse_node.get_object_value(User)