Skip to content
Merged
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
15 changes: 14 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15", "3.14t", "3.15t"]

steps:
- uses: actions/checkout@v7
Expand All @@ -70,6 +70,7 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true

- name: Install Python build and test tools
run: python -m pip install maturin pytest
Expand All @@ -80,6 +81,18 @@ jobs:
- name: Install Python wheel
run: python -m pip install --force-reinstall target/wheels/*.whl

- name: Verify free-threaded import keeps the GIL disabled
if: endsWith(matrix.python-version, 't')
run: |
python - <<'PYTHON'
import sys
import sysconfig
import fasttime

assert sysconfig.get_config_var("Py_GIL_DISABLED") == 1
assert not sys._is_gil_enabled()
PYTHON

- name: Run Python tests
run: python -m pytest python/tests

Expand Down
24 changes: 24 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ timestamp_ns = dt.unix_timestamp_nanos() # nanoseconds
dt_from_ts = fasttime.DateTime.from_unix_timestamp(timestamp, 123_456_789)
```

## Pickle serialization

`DateTime` and `Duration` support `pickle.dumps()` and `pickle.loads()` with all
pickle protocols supported by your Python version. Integer nanoseconds preserve
full precision, including negative values and the complete supported value ranges.

```python
import pickle
import fasttime

before_epoch = fasttime.DateTime.from_unix_timestamp(0, -1)
restored = pickle.loads(pickle.dumps(before_epoch))
assert restored == before_epoch
assert restored.unix_timestamp_nanos() == -1
assert hash(restored) == hash(before_epoch)

duration = fasttime.Duration.nanoseconds(-1_000_000_001)
assert pickle.loads(pickle.dumps(duration)).total_nanos() == -1_000_000_001
```

Reconstruction uses the public `Duration.nanoseconds()` and
`DateTime.from_unix_timestamp()` methods. The other fasttime types do not yet
support pickling. As with any pickle data, only deserialize trusted inputs.

## API Reference

### Date
Expand Down
3 changes: 3 additions & 0 deletions python/fasttime/fasttime.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This file provides type hints for the fasttime module, which is
implemented in Rust via PyO3.
"""

from collections.abc import Callable
from typing import ClassVar

class Weekday:
Expand Down Expand Up @@ -158,6 +159,7 @@ class Duration:
"""Get total nanoseconds as an integer."""
...

def __reduce__(self) -> tuple[Callable[[int], Duration], tuple[int]]: ...
def __add__(self, other: Duration) -> Duration: ...
def __sub__(self, other: Duration) -> Duration: ...
def __neg__(self) -> Duration: ...
Expand Down Expand Up @@ -197,6 +199,7 @@ class DateTime:
"""Get Unix timestamp in nanoseconds."""
...

def __reduce__(self) -> tuple[Callable[[int, int], DateTime], tuple[int, int]]: ...
def add_duration(self, dur: Duration) -> DateTime:
"""Add a duration to this DateTime."""
...
Expand Down
133 changes: 133 additions & 0 deletions python/tests/test_pickle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Pickle regressions for nanosecond-precision DateTime and Duration values."""

import pickle
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor

import fasttime
import pytest

PROTOCOLS = [None, -1, *range(pickle.HIGHEST_PROTOCOL + 1)]
DURATION_NANOS = [
-(2**127),
-(2**63) - 1,
-1_000_000_001,
-1,
0,
1,
1_000_000_001,
2**63 + 1,
2**127 - 1,
]
DATETIME_PARTS = [
(-(2**31), 1, 1, 0, 0, 0, 0),
(-1, 12, 31, 23, 59, 59, 999_999_999),
(0, 2, 29, 12, 30, 45, 123_456_789),
(1, 1, 1, 0, 0, 0, 1),
(1969, 12, 31, 23, 59, 59, 999_999_999),
(1970, 1, 1, 0, 0, 0, 0),
(1970, 1, 1, 0, 0, 0, 1),
(2024, 2, 29, 12, 30, 45, 123_456_789),
(9999, 12, 31, 23, 59, 59, 999_999_999),
(2**31 - 1, 12, 31, 23, 59, 59, 999_999_999),
]


@pytest.mark.parametrize("protocol", PROTOCOLS)
@pytest.mark.parametrize("nanos", DURATION_NANOS)
def test_duration_pickle(nanos: int, protocol: int | None) -> None:
original = fasttime.Duration.nanoseconds(nanos)
restored = pickle.loads(pickle.dumps(original, protocol=protocol))

assert type(restored) is fasttime.Duration
assert restored is not original
assert restored.total_nanos() == nanos
assert restored == original
assert hash(restored) == hash(original)
assert {original: "value"}[restored] == "value"
assert len({original, restored}) == 1


@pytest.mark.parametrize("protocol", PROTOCOLS)
@pytest.mark.parametrize("parts", DATETIME_PARTS)
def test_datetime_pickle(parts: tuple[int, ...], protocol: int | None) -> None:
year, month, day, hour, minute, second, nanos = parts
original = fasttime.DateTime(
fasttime.Date(year, month, day), fasttime.Time(hour, minute, second, nanos)
)
restored = pickle.loads(pickle.dumps(original, protocol=protocol))

assert type(restored) is fasttime.DateTime
assert restored is not original
assert (
restored.date.year,
restored.date.month,
restored.date.day,
restored.time.hour,
restored.time.minute,
restored.time.second,
restored.time.nanosecond,
) == parts
assert restored.unix_timestamp_nanos() == original.unix_timestamp_nanos()
assert restored == original
assert hash(restored) == hash(original)
assert {original: "value"}[restored] == "value"
assert len({original, restored}) == 1


@pytest.mark.parametrize("protocol", PROTOCOLS)
def test_datetime_pickle_negative_nanoseconds(protocol: int | None) -> None:
original = fasttime.DateTime.from_unix_timestamp(0, -1)
restored = pickle.loads(pickle.dumps(original, protocol=protocol))
assert restored.unix_timestamp_nanos() == -1
assert restored.unix_timestamp() == -1
assert restored.time.nanosecond == 999_999_999
assert restored == original
assert hash(restored) == hash(original)


def test_pickle_fresh_interpreter() -> None:
"""Reconstruction callables must resolve without prior class registration."""
values = [
fasttime.Duration.nanoseconds(-(2**127)),
fasttime.Duration.nanoseconds(2**127 - 1),
fasttime.DateTime.from_unix_timestamp(0, -1),
]
# Include each protocol in the payload so a clean process exercises them all.
payload = pickle.dumps([pickle.dumps(values, protocol=p) for p in PROTOCOLS])
script = """
import pickle
import sys
import fasttime

for payload in pickle.loads(sys.stdin.buffer.read()):
minimum, maximum, before_epoch = pickle.loads(payload)
assert type(minimum) is fasttime.Duration
assert type(maximum) is fasttime.Duration
assert type(before_epoch) is fasttime.DateTime
assert minimum.total_nanos() == -(2**127)
assert maximum.total_nanos() == 2**127 - 1
assert before_epoch.unix_timestamp_nanos() == -1
"""
subprocess.run(
[sys.executable, "-c", script], input=payload, check=True, timeout=30
)


def test_pickle_shared_values_across_threads() -> None:
"""Exercise shared instances, including when the interpreter has no GIL."""
values = (
fasttime.Duration.nanoseconds(-(2**127)),
fasttime.DateTime.from_unix_timestamp(0, -1),
)

def roundtrip(_: int) -> None:
for protocol in PROTOCOLS:
for original in values:
restored = pickle.loads(pickle.dumps(original, protocol=protocol))
assert restored == original
assert hash(restored) == hash(original)

with ThreadPoolExecutor(max_workers=8) as executor:
list(executor.map(roundtrip, range(32)))
18 changes: 18 additions & 0 deletions src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,14 @@ impl PyDuration {
self.0.total_nanos()
}

/// Reconstruct from integer nanoseconds without losing precision.
fn __reduce__<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyAny>, (i128,))> {
Ok((
py.get_type::<Self>().getattr("nanoseconds")?,
(self.0.total_nanos(),),
))
}

fn __add__(&self, other: &Self) -> Self {
PyDuration(self.0 + other.0)
}
Expand Down Expand Up @@ -501,6 +509,16 @@ impl PyDateTime {
self.0.unix_timestamp_nanos()
}

/// Reconstruct from whole Unix seconds and a nonnegative nanosecond part.
fn __reduce__<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyAny>, (i64, u32))> {
// unix_timestamp() floors negative timestamps; nanosecond is always
// in 0..1_000_000_000, including the instant just before the epoch.
Ok((
py.get_type::<Self>().getattr("from_unix_timestamp")?,
(self.0.unix_timestamp(), self.0.time.nanosecond),
))
}

/// Add a duration to this DateTime.
///
/// Args:
Expand Down
Loading