diff --git a/atlassian/bitbucket/base.py b/atlassian/bitbucket/base.py index 750624076..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,17 +168,53 @@ 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): - 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 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 + if _dateutil_parser is not None: + try: + # 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 value is None: + # 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 new file mode 100644 index 000000000..12421271b --- /dev/null +++ b/tests/test_bug_reproduction.py @@ -0,0 +1,12 @@ +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