From 72f8169ede392a0ab01ec15f628df89514ecb06e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:09:37 +0000 Subject: [PATCH 1/2] fix: add type validation in DatetimeParser.parse() to handle list, dict, and None inputs This fix addresses TypeError issues reported in oncall issue #9163 and similar issues where DatetimeParser.parse() is called with invalid input types (list, dict, None) instead of expected timestamp types (str, int, float). The error occurred when concurrent cursor state converters attempted to parse partitioned state structures that contained lists or nested dictionaries as cursor values. This affected multiple connectors including source-zendesk-support, source-intercom, and source-stripe. Changes: - Add explicit type validation at the start of parse() to reject None, list, and dict inputs - Provide clear error messages indicating the expected types vs actual type received - Add try-catch blocks around timestamp conversions for better error handling - Add comprehensive test coverage for all invalid input types Fixes: airbytehq/oncall#9163 Related: airbytehq/oncall#9126, airbytehq/oncall#9138, airbytehq/oncall#8674, airbytehq/oncall#8154, airbytehq/oncall#8386, airbytehq/oncall#7783, airbytehq/oncall#7473 Co-Authored-By: unknown <> --- .../declarative/datetime/datetime_parser.py | 31 ++++++++-- .../datetime/test_datetime_parser.py | 58 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/airbyte_cdk/sources/declarative/datetime/datetime_parser.py b/airbyte_cdk/sources/declarative/datetime/datetime_parser.py index 2707ffe114..6ae717d94e 100644 --- a/airbyte_cdk/sources/declarative/datetime/datetime_parser.py +++ b/airbyte_cdk/sources/declarative/datetime/datetime_parser.py @@ -19,6 +19,17 @@ class DatetimeParser: _UNIX_EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) def parse(self, date: Union[str, int], format: str) -> datetime.datetime: + if date is None: + raise ValueError( + f"Cannot parse None as a datetime. Expected a string, integer, or float representing a timestamp." + ) + + if isinstance(date, (list, dict)): + raise TypeError( + f"Cannot parse {type(date).__name__} as a datetime. " + f"Expected a string, integer, or float representing a timestamp, but got: {date}" + ) + # "%s" is a valid (but unreliable) directive for formatting, but not for parsing # It is defined as # The number of seconds since the Epoch, 1970-01-01 00:00:00+0000 (UTC). https://man7.org/linux/man-pages/man3/strptime.3.html @@ -26,13 +37,25 @@ def parse(self, date: Union[str, int], format: str) -> datetime.datetime: # The recommended way to parse a date from its timestamp representation is to use datetime.fromtimestamp # See https://stackoverflow.com/a/4974930 if format == "%s": - return datetime.datetime.fromtimestamp(int(date), tz=datetime.timezone.utc) + try: + return datetime.datetime.fromtimestamp(int(date), tz=datetime.timezone.utc) + except (ValueError, OverflowError) as e: + raise ValueError(f"Cannot parse '{date}' as a Unix timestamp: {e}") elif format == "%s_as_float": - return datetime.datetime.fromtimestamp(float(date), tz=datetime.timezone.utc) + try: + return datetime.datetime.fromtimestamp(float(date), tz=datetime.timezone.utc) + except (ValueError, OverflowError) as e: + raise ValueError(f"Cannot parse '{date}' as a float Unix timestamp: {e}") elif format == "%epoch_microseconds": - return self._UNIX_EPOCH + datetime.timedelta(microseconds=int(date)) + try: + return self._UNIX_EPOCH + datetime.timedelta(microseconds=int(date)) + except (ValueError, OverflowError) as e: + raise ValueError(f"Cannot parse '{date}' as epoch microseconds: {e}") elif format == "%ms": - return self._UNIX_EPOCH + datetime.timedelta(milliseconds=int(date)) + try: + return self._UNIX_EPOCH + datetime.timedelta(milliseconds=int(date)) + except (ValueError, OverflowError) as e: + raise ValueError(f"Cannot parse '{date}' as milliseconds: {e}") elif "%_ms" in format: format = format.replace("%_ms", "%f") parsed_datetime = datetime.datetime.strptime(str(date), format) diff --git a/unit_tests/sources/declarative/datetime/test_datetime_parser.py b/unit_tests/sources/declarative/datetime/test_datetime_parser.py index b9da9852f9..d6d1e179ff 100644 --- a/unit_tests/sources/declarative/datetime/test_datetime_parser.py +++ b/unit_tests/sources/declarative/datetime/test_datetime_parser.py @@ -125,3 +125,61 @@ def test_format_datetime(input_dt: datetime.datetime, datetimeformat: str, expec parser = DatetimeParser() output_date = parser.format(input_dt, datetimeformat) assert output_date == expected_output + + +@pytest.mark.parametrize( + "input_date, date_format, expected_error_type, expected_error_message", + [ + ( + None, + "%s", + ValueError, + "Cannot parse None as a datetime", + ), + ( + [1694902531, 1694902532], + "%s", + TypeError, + "Cannot parse list as a datetime", + ), + ( + {"timestamp": 1694902531}, + "%s", + TypeError, + "Cannot parse dict as a datetime", + ), + ( + [1694902531], + "%s_as_float", + TypeError, + "Cannot parse list as a datetime", + ), + ( + None, + "%ms", + ValueError, + "Cannot parse None as a datetime", + ), + ( + {"states": [{"cursor": {"updated_at": "1694902531"}}]}, + "%s", + TypeError, + "Cannot parse dict as a datetime", + ), + ], + ids=[ + "test_parse_none_raises_value_error", + "test_parse_list_raises_type_error", + "test_parse_dict_raises_type_error", + "test_parse_list_float_format_raises_type_error", + "test_parse_none_ms_format_raises_value_error", + "test_parse_partitioned_state_dict_raises_type_error", + ], +) +def test_parse_invalid_types( + input_date, date_format: str, expected_error_type, expected_error_message: str +): + parser = DatetimeParser() + with pytest.raises(expected_error_type) as exc_info: + parser.parse(input_date, date_format) + assert expected_error_message in str(exc_info.value) From acf02f61c7ecedd8392c1bae76c523fd0a724b32 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:54:19 +0000 Subject: [PATCH 2/2] fix: remove trailing whitespace to pass Ruff format check Co-Authored-By: unknown <> --- airbyte_cdk/sources/declarative/datetime/datetime_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/airbyte_cdk/sources/declarative/datetime/datetime_parser.py b/airbyte_cdk/sources/declarative/datetime/datetime_parser.py index 6ae717d94e..d34308276f 100644 --- a/airbyte_cdk/sources/declarative/datetime/datetime_parser.py +++ b/airbyte_cdk/sources/declarative/datetime/datetime_parser.py @@ -23,13 +23,13 @@ def parse(self, date: Union[str, int], format: str) -> datetime.datetime: raise ValueError( f"Cannot parse None as a datetime. Expected a string, integer, or float representing a timestamp." ) - + if isinstance(date, (list, dict)): raise TypeError( f"Cannot parse {type(date).__name__} as a datetime. " f"Expected a string, integer, or float representing a timestamp, but got: {date}" ) - + # "%s" is a valid (but unreliable) directive for formatting, but not for parsing # It is defined as # The number of seconds since the Epoch, 1970-01-01 00:00:00+0000 (UTC). https://man7.org/linux/man-pages/man3/strptime.3.html