Skip to content

Commit 67edc53

Browse files
committed
Accept lowercase z and timestamps without seconds
1 parent 38f6922 commit 67edc53

2 files changed

Lines changed: 15 additions & 7 deletions

File tree

‎pyiceberg/utils/datetime.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@
3131
ISO_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d{1,6})?")
3232
ISO_TIMESTAMP_NANO = re.compile(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(.\d{1,6})?(\d{1,3})?")
3333
EPOCH_TIMESTAMPTZ = datetime.fromisoformat("1970-01-01T00:00:00.000000+00:00")
34-
ISO_TIMESTAMPTZ = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d{1,6})?([-+]\d{2}:\d{2}|Z)")
35-
ISO_TIMESTAMPTZ_NANO = re.compile(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(.\d{1,6})?(\d{1,3})?([-+]\d{2}:\d{2}|Z)")
34+
# Seconds are optional and the zone can be an offset or the UTC designator, as in Java's ISO_DATE_TIME
35+
ISO_TIMESTAMPTZ = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(.\d{1,6})?)?([-+]\d{2}:\d{2}|[Zz])")
36+
ISO_TIMESTAMPTZ_NANO = re.compile(r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(?:(:\d{2})(.\d{1,6})?(\d{1,3})?)?([-+]\d{2}:\d{2}|[Zz])")
3637

3738

3839
def micros_to_days(timestamp: int) -> int:
@@ -135,19 +136,20 @@ def timestamp_to_nanos(timestamp_str: str) -> int:
135136

136137
def _utc_offset(offset: str) -> str:
137138
# datetime.fromisoformat only accepts the "Z" designator from Python 3.11
138-
return "+00:00" if offset == "Z" else offset
139+
return "+00:00" if offset in ("Z", "z") else offset
139140

140141

141142
def timestamptz_to_nanos(timestamptz_str: str) -> int:
142143
"""Convert an ISO-8601 formatted timestamp with zone to nanoseconds from 1970-01-01T00:00:00.000000000+00:00."""
143144
if match := ISO_TIMESTAMPTZ_NANO.fullmatch(timestamptz_str):
144145
# Python datetime does not have native nanoseconds support
145146
# Hence we need to extract nanoseconds timestamp manually
146-
# group(3) holds the sub-microsecond digits (fraction positions 7-9), so
147+
# group(4) holds the sub-microsecond digits (fraction positions 7-9), so
147148
# right-pad to 3 digits before reading them as nanoseconds (e.g. "7" -> 700).
148-
ns_str = (match.group(3) or "0").ljust(3, "0")
149-
ms_str = match.group(2) if match.group(2) else ""
150-
timestamptz_str_without_ns_str = match.group(1) + ms_str + _utc_offset(match.group(4))
149+
ns_str = (match.group(4) or "0").ljust(3, "0")
150+
seconds_str = match.group(2) or ""
151+
ms_str = match.group(3) or ""
152+
timestamptz_str_without_ns_str = match.group(1) + seconds_str + ms_str + _utc_offset(match.group(5))
151153
return datetime_to_nanos(datetime.fromisoformat(timestamptz_str_without_ns_str)) + int(ns_str)
152154
if ISO_TIMESTAMP_NANO.fullmatch(timestamptz_str):
153155
# When we can match a timestamp without a zone, we can give a more specific error

‎tests/utils/test_datetime.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ def test_timestamp_to_nanos_invalid_timestamp() -> None:
142142
("2025-02-23T16:21:44.375612001-04:00", 1740342104375612001),
143143
("2025-02-23T20:21:44.375612001Z", 1740342104375612001),
144144
("1970-01-01T00:00:00Z", 0),
145+
("1970-01-01T00:00:00z", 0),
146+
("2025-02-23T20:21Z", 1740342060000000000),
147+
("2025-02-23T16:21-04:00", 1740342060000000000),
145148
],
146149
)
147150
def test_timestamptz_to_nanos(timestamp: str, nanos: int) -> None:
@@ -155,6 +158,9 @@ def test_timestamptz_to_nanos(timestamp: str, nanos: int) -> None:
155158
("2025-02-23T20:21:44.375612+00:00", 1740342104375612),
156159
("2025-02-23T20:21:44.375612Z", 1740342104375612),
157160
("1970-01-01T00:00:00Z", 0),
161+
("1970-01-01T00:00:00z", 0),
162+
("2025-02-23T20:21Z", 1740342060000000),
163+
("2025-02-23T16:21-04:00", 1740342060000000),
158164
],
159165
)
160166
def test_timestamptz_to_micros(timestamp: str, micros: int) -> None:

0 commit comments

Comments
 (0)