From cfbc483f18e9506e2d38d9d49ae81ec18481c93e Mon Sep 17 00:00:00 2001 From: Hariharan P R Date: Mon, 6 Oct 2025 11:27:33 +0530 Subject: [PATCH 1/2] [Bitbucket] Robust datetime parsing for commit dates (#1589) Normalize timestamps like 'YYYY-MM-DDTHH:MM:SS+00:00Z', handle Python <3.7 %z format, and try multiple datetime formats. --- atlassian/bitbucket/base.py | 38 +++++++++++++++++++++++++--------- tests/test_bug_reproduction.py | 14 +++++++++++++ 2 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 tests/test_bug_reproduction.py diff --git a/atlassian/bitbucket/base.py b/atlassian/bitbucket/base.py index 750624076..8349ba830 100644 --- a/atlassian/bitbucket/base.py +++ b/atlassian/bitbucket/base.py @@ -160,17 +160,35 @@ def get_time(self, id): return value_str if isinstance(value_str, str): - # The format contains a : in the timezone which is supported from 3.7 on. - if sys.version_info <= (3, 7): + # Normalize timestamps that include a timezone followed by an + # extraneous 'Z' (e.g. '2025-09-18T21:26:38+00:00Z') by removing the + # final 'Z'. This pattern appears in some Bitbucket responses. + if re.search(r"[+-]\d{2}:\d{2}Z$", value_str): + value_str = value_str[:-1] + + # Python < 3.7 does not accept a ':' in the %z timezone, so strip + # it for older interpreters. + if sys.version_info < (3, 7): value_str = RE_TIMEZONE.sub(r"\1\2", value_str) - try: - value_str = value_str[:26] + "Z" - value = datetime.strptime(value_str, self.CONF_TIMEFORMAT) - except ValueError: - value = datetime.strptime( - value_str, - "%Y-%m-%dT%H:%M:%S.%fZ", - ) + + # Try several likely formats, from most to least specific. + value = None + for fmt in ( + "%Y-%m-%dT%H:%M:%S.%f%z", + "%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", + ): + try: + value = datetime.strptime(value_str, fmt) + break + except ValueError: + continue + + # If parsing failed for all formats, leave the original string + # intact so the timeformat lambda can decide what to do with it. + if value is None: + value = value_str else: value = value_str diff --git a/tests/test_bug_reproduction.py b/tests/test_bug_reproduction.py new file mode 100644 index 000000000..16021fabd --- /dev/null +++ b/tests/test_bug_reproduction.py @@ -0,0 +1,14 @@ +import pytest + +from atlassian.bitbucket.cloud.repositories.commits import Commit + + +def test_commit_date_parsing_raises_value_error(): + """Ensure Commit.date handles timestamps like '...+00:00Z'.""" + + data = {"type": "commit", "date": "2025-09-18T21:26:38+00:00Z"} + + commit = Commit(data) + + result = commit.date + assert result is not None From 0bb19a889b41e2c5d11a8c624c22d2b8e751cc48 Mon Sep 17 00:00:00 2001 From: Hariharan P R Date: Mon, 6 Oct 2025 11:56:24 +0530 Subject: [PATCH 2/2] [Bitbucket] Robust datetime parsing for commit dates (#1589) Normalize timestamps like 'YYYY-MM-DDTHH:MM:SS+00:00Z', handle Python <3.7 %z format, and try multiple datetime formats. --- atlassian/bitbucket/base.py | 76 +++++++++++++++++++++++----------- tests/test_bug_reproduction.py | 2 - 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/atlassian/bitbucket/base.py b/atlassian/bitbucket/base.py index 8349ba830..8252131d5 100644 --- a/atlassian/bitbucket/base.py +++ b/atlassian/bitbucket/base.py @@ -10,6 +10,14 @@ RE_TIMEZONE = re.compile(r"(\d{2}):(\d{2})$") +# dateutil is optional but handles many ISO8601 variants more robustly than +# strptime across Python versions. Import if available and fall back to the +# built-in parsing logic below when it's not. +try: + from dateutil import parser as _dateutil_parser # type: ignore +except Exception: # pragma: no cover - optional dependency + _dateutil_parser = None + class BitbucketBase(AtlassianRestAPI): CONF_TIMEFORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" @@ -160,35 +168,53 @@ def get_time(self, id): return value_str if isinstance(value_str, str): - # Normalize timestamps that include a timezone followed by an - # extraneous 'Z' (e.g. '2025-09-18T21:26:38+00:00Z') by removing the - # final 'Z'. This pattern appears in some Bitbucket responses. - if re.search(r"[+-]\d{2}:\d{2}Z$", value_str): - value_str = value_str[:-1] - - # Python < 3.7 does not accept a ':' in the %z timezone, so strip - # it for older interpreters. - if sys.version_info < (3, 7): - value_str = RE_TIMEZONE.sub(r"\1\2", value_str) - - # Try several likely formats, from most to least specific. + # Try to use dateutil when available because it correctly handles + # many ISO8601 edge cases across Python versions (for example, + # '2025-09-18T21:26:38+00:00Z'). If dateutil isn't present or it + # fails to parse, fall back to the existing strptime-based logic. value = None - for fmt in ( - "%Y-%m-%dT%H:%M:%S.%f%z", - "%Y-%m-%dT%H:%M:%S%z", - "%Y-%m-%dT%H:%M:%S.%f", - "%Y-%m-%dT%H:%M:%S", - ): + if _dateutil_parser is not None: try: - value = datetime.strptime(value_str, fmt) - break - except ValueError: - continue + # isoparse is preferable for strict ISO parsing when + # available; otherwise fall back to parse. + if hasattr(_dateutil_parser, "isoparse"): + value = _dateutil_parser.isoparse(value_str) + else: + value = _dateutil_parser.parse(value_str) + except Exception: + # If dateutil can't parse it for any reason, we'll + # continue to the manual fallback below. + value = None - # If parsing failed for all formats, leave the original string - # intact so the timeformat lambda can decide what to do with it. if value is None: - value = value_str + # Normalize timestamps that include a timezone followed by an + # extraneous 'Z' (e.g. '2025-09-18T21:26:38+00:00Z') by removing the + # final 'Z'. This pattern appears in some Bitbucket responses. + if re.search(r"[+-]\d{2}:\d{2}Z$", value_str): + value_str = value_str[:-1] + + # Python < 3.7 does not accept a ':' in the %z timezone, so strip + # it for older interpreters. + if sys.version_info < (3, 7): + value_str = RE_TIMEZONE.sub(r"\1\2", value_str) + + # Try several likely formats, from most to least specific. + for fmt in ( + "%Y-%m-%dT%H:%M:%S.%f%z", + "%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", + ): + try: + value = datetime.strptime(value_str, fmt) + break + except ValueError: + continue + + # If parsing failed for all formats, leave the original string + # intact so the timeformat lambda can decide what to do with it. + if value is None: + value = value_str else: value = value_str diff --git a/tests/test_bug_reproduction.py b/tests/test_bug_reproduction.py index 16021fabd..12421271b 100644 --- a/tests/test_bug_reproduction.py +++ b/tests/test_bug_reproduction.py @@ -1,5 +1,3 @@ -import pytest - from atlassian.bitbucket.cloud.repositories.commits import Commit