Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 55 additions & 11 deletions atlassian/bitbucket/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions tests/test_bug_reproduction.py
Original file line number Diff line number Diff line change
@@ -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