Skip to content

Commit 8d4658f

Browse files
Add JSON single-value serialization for timestamp_ns and timestamptz_ns
1 parent ebbc0ba commit 8d4658f

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

@@ -463,6 +467,22 @@ def _(_: TimestamptzType, val: int | datetime) -> str:
463467
return to_human_timestamptz(val)
464468

465469

470+
@to_json.register(TimestampNanoType)
471+
def _(_: TimestampNanoType, val: int | datetime) -> str:
472+
"""Python datetime (without timezone) or nanoseconds since epoch serializes into an ISO8601 timestamp."""
473+
if isinstance(val, datetime):
474+
val = datetime_to_nanos(val)
475+
return to_human_timestamp_ns(val)
476+
477+
478+
@to_json.register(TimestamptzNanoType)
479+
def _(_: TimestamptzNanoType, val: int | datetime) -> str:
480+
"""Python datetime (with timezone) or nanoseconds since epoch serializes into an ISO8601 timestamp."""
481+
if isinstance(val, datetime):
482+
val = datetime_to_nanos(val)
483+
return to_human_timestamptz_ns(val)
484+
485+
466486
@to_json.register(FloatType)
467487
@to_json.register(DoubleType)
468488
def _(_: FloatType | DoubleType, val: float) -> float:
@@ -613,6 +633,34 @@ def _(_: TimestamptzType, val: str | int | datetime) -> datetime:
613633
return val
614634

615635

636+
@from_json.register(TimestampNanoType)
637+
def _(_: TimestampNanoType, val: str | int | datetime) -> int:
638+
"""JSON ISO8601 string into nanoseconds since epoch.
639+
640+
Python datetime cannot hold nanoseconds, so the value stays an int.
641+
"""
642+
if isinstance(val, str):
643+
return timestamp_to_nanos(val)
644+
elif isinstance(val, datetime):
645+
return datetime_to_nanos(val)
646+
else:
647+
return val
648+
649+
650+
@from_json.register(TimestamptzNanoType)
651+
def _(_: TimestamptzNanoType, val: str | int | datetime) -> int:
652+
"""JSON ISO8601 string into nanoseconds since epoch.
653+
654+
Python datetime cannot hold nanoseconds, so the value stays an int.
655+
"""
656+
if isinstance(val, str):
657+
return timestamptz_to_nanos(val)
658+
elif isinstance(val, datetime):
659+
return datetime_to_nanos(val)
660+
else:
661+
return val
662+
663+
616664
@from_json.register(FloatType)
617665
@from_json.register(DoubleType)
618666
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
@@ -575,6 +575,15 @@ def test_datetime_obj_to_bytes(primitive_type: PrimitiveType, value: datetime |
575575
(TimeType(), time(22, 31, 8, 123456), "22:31:08.123456"),
576576
(TimestampType(), datetime(2017, 11, 16, 22, 31, 8, 123456), "2017-11-16T22:31:08.123456"),
577577
(TimestamptzType(), datetime(2017, 11, 16, 22, 31, 8, 123456, tzinfo=timezone.utc), "2017-11-16T22:31:08.123456+00:00"),
578+
(TimestampNanoType(), 1510871468123456789, "2017-11-16T22:31:08.123456789"),
579+
(TimestamptzNanoType(), 1510871468123456789, "2017-11-16T22:31:08.123456789+00:00"),
580+
# Python datetime only carries microseconds, so the last three digits are zero
581+
(TimestampNanoType(), datetime(2017, 11, 16, 22, 31, 8, 123456), "2017-11-16T22:31:08.123456000"),
582+
(
583+
TimestamptzNanoType(),
584+
datetime(2017, 11, 16, 22, 31, 8, 123456, tzinfo=timezone.utc),
585+
"2017-11-16T22:31:08.123456000+00:00",
586+
),
578587
(StringType(), "iceberg", "iceberg"),
579588
(BinaryType(), b"\x01\x02\x03\xff", "010203ff"),
580589
(FixedType(4), b"\x01\x02\x03\xff", "010203ff"),
@@ -600,6 +609,8 @@ def test_json_single_serialization(primitive_type: PrimitiveType, value: Any, ex
600609
(TimeType(), time(22, 31, 8, 123456)),
601610
(TimestampType(), datetime(2017, 11, 16, 22, 31, 8, 123456)),
602611
(TimestamptzType(), datetime(2017, 11, 16, 22, 31, 8, 123456, tzinfo=timezone.utc)),
612+
(TimestampNanoType(), 1510871468123456789),
613+
(TimestamptzNanoType(), 1510871468123456789),
603614
(StringType(), "iceberg"),
604615
(BinaryType(), b"\x01\x02\x03\xff"),
605616
(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,
@@ -947,3 +949,22 @@ def test_decimal_scale_validation() -> None:
947949

948950
with pytest.raises(ValidationError, match="Decimal scale must be between 0 and the precision"):
949951
DecimalType(5, 10)
952+
953+
954+
@pytest.mark.parametrize(
955+
"field_type, expected_json",
956+
[
957+
(TimestampNanoType(), "2017-11-16T22:31:08.123456789"),
958+
(TimestamptzNanoType(), "2017-11-16T22:31:08.123456789+00:00"),
959+
],
960+
)
961+
def test_nested_field_nanosecond_defaults(field_type: PrimitiveType, expected_json: str) -> None:
962+
"""Nanosecond timestamp defaults serialize to ISO8601 and survive a round-trip."""
963+
nanos = 1510871468123456789
964+
field = NestedField(1, "ts", field_type, required=False, initial_default=nanos, write_default=nanos)
965+
966+
serialized = field.model_dump_json()
967+
assert f'"initial-default":"{expected_json}"' in serialized
968+
assert f'"write-default":"{expected_json}"' in serialized
969+
970+
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
@@ -30,6 +30,8 @@
3030
time_to_nanos,
3131
timestamp_to_nanos,
3232
timestamptz_to_nanos,
33+
to_human_timestamp_ns,
34+
to_human_timestamptz_ns,
3335
)
3436

3537
timezones = [
@@ -169,3 +171,32 @@ def test_nanos_to_micros(nanos: int, micros: int) -> None:
169171
)
170172
def test_nanos_to_hours(nanos: int, hours: int) -> None:
171173
assert hours == nanos_to_hours(nanos)
174+
175+
176+
@pytest.mark.parametrize(
177+
"nanos, expected",
178+
[
179+
(0, "1970-01-01T00:00:00.000000000"),
180+
(1510871468123456789, "2017-11-16T22:31:08.123456789"),
181+
# sub-second digits are zero padded to nine positions
182+
(1510871468000000001, "2017-11-16T22:31:08.000000001"),
183+
(-1, "1969-12-31T23:59:59.999999999"),
184+
],
185+
)
186+
def test_to_human_timestamp_ns(nanos: int, expected: str) -> None:
187+
assert to_human_timestamp_ns(nanos) == expected
188+
assert timestamp_to_nanos(expected) == nanos
189+
190+
191+
@pytest.mark.parametrize(
192+
"nanos, expected",
193+
[
194+
(0, "1970-01-01T00:00:00.000000000+00:00"),
195+
(1510871468123456789, "2017-11-16T22:31:08.123456789+00:00"),
196+
(1510871468000000001, "2017-11-16T22:31:08.000000001+00:00"),
197+
(-1, "1969-12-31T23:59:59.999999999+00:00"),
198+
],
199+
)
200+
def test_to_human_timestamptz_ns(nanos: int, expected: str) -> None:
201+
assert to_human_timestamptz_ns(nanos) == expected
202+
assert timestamptz_to_nanos(expected) == nanos

0 commit comments

Comments
 (0)