Skip to content

Commit 2c07ba3

Browse files
Add JSON single-value serialization for timestamp_ns and timestamptz_ns
1 parent 68898e5 commit 2c07ba3

5 files changed

Lines changed: 125 additions & 0 deletions

File tree

pyiceberg/conversions.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,15 @@
7676
time_str_to_micros,
7777
time_to_micros,
7878
timestamp_to_micros,
79+
timestamp_to_nanos,
7980
timestamptz_to_micros,
81+
timestamptz_to_nanos,
8082
to_human_day,
8183
to_human_time,
8284
to_human_timestamp,
85+
to_human_timestamp_ns,
8386
to_human_timestamptz,
87+
to_human_timestamptz_ns,
8488
)
8589
from pyiceberg.utils.decimal import decimal_to_bytes, unscaled_to_decimal
8690

@@ -457,6 +461,22 @@ def _(_: TimestamptzType, val: int | datetime) -> str:
457461
return to_human_timestamptz(val)
458462

459463

464+
@to_json.register(TimestampNanoType)
465+
def _(_: TimestampNanoType, val: int | datetime) -> str:
466+
"""Python datetime (without timezone) or nanoseconds since epoch serializes into an ISO8601 timestamp."""
467+
if isinstance(val, datetime):
468+
val = datetime_to_nanos(val)
469+
return to_human_timestamp_ns(val)
470+
471+
472+
@to_json.register(TimestamptzNanoType)
473+
def _(_: TimestamptzNanoType, val: int | datetime) -> str:
474+
"""Python datetime (with timezone) or nanoseconds since epoch serializes into an ISO8601 timestamp."""
475+
if isinstance(val, datetime):
476+
val = datetime_to_nanos(val)
477+
return to_human_timestamptz_ns(val)
478+
479+
460480
@to_json.register(FloatType)
461481
@to_json.register(DoubleType)
462482
def _(_: FloatType | DoubleType, val: float) -> float:
@@ -607,6 +627,34 @@ def _(_: TimestamptzType, val: str | int | datetime) -> datetime:
607627
return val
608628

609629

630+
@from_json.register(TimestampNanoType)
631+
def _(_: TimestampNanoType, val: str | int | datetime) -> int:
632+
"""JSON ISO8601 string into nanoseconds since epoch.
633+
634+
Python datetime cannot hold nanoseconds, so the value stays an int.
635+
"""
636+
if isinstance(val, str):
637+
return timestamp_to_nanos(val)
638+
elif isinstance(val, datetime):
639+
return datetime_to_nanos(val)
640+
else:
641+
return val
642+
643+
644+
@from_json.register(TimestamptzNanoType)
645+
def _(_: TimestamptzNanoType, val: str | int | datetime) -> int:
646+
"""JSON ISO8601 string into nanoseconds since epoch.
647+
648+
Python datetime cannot hold nanoseconds, so the value stays an int.
649+
"""
650+
if isinstance(val, str):
651+
return timestamptz_to_nanos(val)
652+
elif isinstance(val, datetime):
653+
return datetime_to_nanos(val)
654+
else:
655+
return val
656+
657+
610658
@from_json.register(FloatType)
611659
@from_json.register(DoubleType)
612660
def _(_: FloatType | DoubleType, val: float) -> float:

pyiceberg/utils/datetime.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,20 @@ def to_human_timestamp(timestamp_micros: int) -> str:
222222
return (EPOCH_TIMESTAMP + timedelta(microseconds=timestamp_micros)).isoformat()
223223

224224

225+
def to_human_timestamp_ns(timestamp_nanos: int) -> str:
226+
"""Convert a TimestampNanoType value to human string."""
227+
seconds, nanos = divmod(timestamp_nanos, 1_000_000_000)
228+
timestamp = EPOCH_TIMESTAMP + timedelta(seconds=seconds)
229+
return f"{timestamp.isoformat(timespec='seconds')}.{nanos:09d}"
230+
231+
232+
def to_human_timestamptz_ns(timestamp_nanos: int) -> str:
233+
"""Convert a TimestamptzNanoType value to human string."""
234+
seconds, nanos = divmod(timestamp_nanos, 1_000_000_000)
235+
timestamp = EPOCH_TIMESTAMPTZ + timedelta(seconds=seconds)
236+
return f"{timestamp.replace(tzinfo=None).isoformat(timespec='seconds')}.{nanos:09d}+00:00"
237+
238+
225239
def micros_to_hours(micros: int) -> int:
226240
"""Convert a timestamp in microseconds to hours from 1970-01-01T00:00."""
227241
return micros // 3_600_000_000

tests/test_conversions.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,15 @@ def test_datetime_obj_to_bytes(primitive_type: PrimitiveType, value: datetime |
574574
(TimeType(), time(22, 31, 8, 123456), "22:31:08.123456"),
575575
(TimestampType(), datetime(2017, 11, 16, 22, 31, 8, 123456), "2017-11-16T22:31:08.123456"),
576576
(TimestamptzType(), datetime(2017, 11, 16, 22, 31, 8, 123456, tzinfo=timezone.utc), "2017-11-16T22:31:08.123456+00:00"),
577+
(TimestampNanoType(), 1510871468123456789, "2017-11-16T22:31:08.123456789"),
578+
(TimestamptzNanoType(), 1510871468123456789, "2017-11-16T22:31:08.123456789+00:00"),
579+
# Python datetime only carries microseconds, so the last three digits are zero
580+
(TimestampNanoType(), datetime(2017, 11, 16, 22, 31, 8, 123456), "2017-11-16T22:31:08.123456000"),
581+
(
582+
TimestamptzNanoType(),
583+
datetime(2017, 11, 16, 22, 31, 8, 123456, tzinfo=timezone.utc),
584+
"2017-11-16T22:31:08.123456000+00:00",
585+
),
577586
(StringType(), "iceberg", "iceberg"),
578587
(BinaryType(), b"\x01\x02\x03\xff", "010203ff"),
579588
(FixedType(4), b"\x01\x02\x03\xff", "010203ff"),
@@ -599,6 +608,8 @@ def test_json_single_serialization(primitive_type: PrimitiveType, value: Any, ex
599608
(TimeType(), time(22, 31, 8, 123456)),
600609
(TimestampType(), datetime(2017, 11, 16, 22, 31, 8, 123456)),
601610
(TimestamptzType(), datetime(2017, 11, 16, 22, 31, 8, 123456, tzinfo=timezone.utc)),
611+
(TimestampNanoType(), 1510871468123456789),
612+
(TimestamptzNanoType(), 1510871468123456789),
602613
(StringType(), "iceberg"),
603614
(BinaryType(), b"\x01\x02\x03\xff"),
604615
(FixedType(4), b"\x01\x02\x03\xff"),

tests/test_types.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@
4343
PrimitiveType,
4444
StringType,
4545
StructType,
46+
TimestampNanoType,
4647
TimestampType,
48+
TimestamptzNanoType,
4749
TimestamptzType,
4850
TimeType,
4951
UUIDType,
@@ -935,3 +937,22 @@ def test_decimal_precision_validation() -> None:
935937

936938
with pytest.raises(ValidationError, match="Decimal precision must be between 1 and 38"):
937939
DecimalType(-5, 2)
940+
941+
942+
@pytest.mark.parametrize(
943+
"field_type, expected_json",
944+
[
945+
(TimestampNanoType(), "2017-11-16T22:31:08.123456789"),
946+
(TimestamptzNanoType(), "2017-11-16T22:31:08.123456789+00:00"),
947+
],
948+
)
949+
def test_nested_field_nanosecond_defaults(field_type: PrimitiveType, expected_json: str) -> None:
950+
"""Nanosecond timestamp defaults serialize to ISO8601 and survive a round-trip."""
951+
nanos = 1510871468123456789
952+
field = NestedField(1, "ts", field_type, required=False, initial_default=nanos, write_default=nanos)
953+
954+
serialized = field.model_dump_json()
955+
assert f'"initial-default":"{expected_json}"' in serialized
956+
assert f'"write-default":"{expected_json}"' in serialized
957+
958+
assert NestedField.model_validate_json(serialized) == field

tests/utils/test_datetime.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
time_to_nanos,
3030
timestamp_to_nanos,
3131
timestamptz_to_nanos,
32+
to_human_timestamp_ns,
33+
to_human_timestamptz_ns,
3234
)
3335

3436
timezones = [
@@ -158,3 +160,32 @@ def test_nanos_to_micros(nanos: int, micros: int) -> None:
158160
)
159161
def test_nanos_to_hours(nanos: int, hours: int) -> None:
160162
assert hours == nanos_to_hours(nanos)
163+
164+
165+
@pytest.mark.parametrize(
166+
"nanos, expected",
167+
[
168+
(0, "1970-01-01T00:00:00.000000000"),
169+
(1510871468123456789, "2017-11-16T22:31:08.123456789"),
170+
# sub-second digits are zero padded to nine positions
171+
(1510871468000000001, "2017-11-16T22:31:08.000000001"),
172+
(-1, "1969-12-31T23:59:59.999999999"),
173+
],
174+
)
175+
def test_to_human_timestamp_ns(nanos: int, expected: str) -> None:
176+
assert to_human_timestamp_ns(nanos) == expected
177+
assert timestamp_to_nanos(expected) == nanos
178+
179+
180+
@pytest.mark.parametrize(
181+
"nanos, expected",
182+
[
183+
(0, "1970-01-01T00:00:00.000000000+00:00"),
184+
(1510871468123456789, "2017-11-16T22:31:08.123456789+00:00"),
185+
(1510871468000000001, "2017-11-16T22:31:08.000000001+00:00"),
186+
(-1, "1969-12-31T23:59:59.999999999+00:00"),
187+
],
188+
)
189+
def test_to_human_timestamptz_ns(nanos: int, expected: str) -> None:
190+
assert to_human_timestamptz_ns(nanos) == expected
191+
assert timestamptz_to_nanos(expected) == nanos

0 commit comments

Comments
 (0)