diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68d67ad..0af6b7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -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 @@ -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 diff --git a/python/README.md b/python/README.md index f10b7ae..b5c3dba 100644 --- a/python/README.md +++ b/python/README.md @@ -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 diff --git a/python/fasttime/fasttime.pyi b/python/fasttime/fasttime.pyi index 27aaffa..8a32789 100644 --- a/python/fasttime/fasttime.pyi +++ b/python/fasttime/fasttime.pyi @@ -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: @@ -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: ... @@ -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.""" ... diff --git a/python/tests/test_pickle.py b/python/tests/test_pickle.py new file mode 100644 index 0000000..87809e6 --- /dev/null +++ b/python/tests/test_pickle.py @@ -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))) diff --git a/src/python.rs b/src/python.rs index f479ca7..ad328ae 100644 --- a/src/python.rs +++ b/src/python.rs @@ -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::().getattr("nanoseconds")?, + (self.0.total_nanos(),), + )) + } + fn __add__(&self, other: &Self) -> Self { PyDuration(self.0 + other.0) } @@ -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::().getattr("from_unix_timestamp")?, + (self.0.unix_timestamp(), self.0.time.nanosecond), + )) + } + /// Add a duration to this DateTime. /// /// Args: