From 3171511465fdcaf1a68d91c199f9d4140fc22e75 Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Wed, 21 Oct 2026 15:57:04 -0500 Subject: [PATCH] rust(feat): Convert DurationPy to and from timedelta Add DurationPy.to_timedelta() and DurationPy.from_timedelta() so Python callers can use RateLimitedErrorPy.retry_delay and other durations with standard library code. to_timedelta() raises OverflowError past timedelta.max; from_timedelta() raises ValueError for negative values. --- rust/CHANGELOG.md | 12 +++++++ .../sift_stream_bindings.pyi | 13 ++++++++ .../sift_stream_bindings/src/stream/retry.rs | 29 ++++++++++++++++ .../sift_stream_bindings/tests/test_retry.py | 33 +++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index f198adb1f..a783431b5 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### What's New + +#### `DurationPy` converts to and from `timedelta` + +##### `sift-stream-bindings` + +`DurationPy.to_timedelta()` returns the duration as a `datetime.timedelta`, so +`RateLimitedErrorPy.retry_delay.to_timedelta()` gives the delay Sift requested in a form that +works with `asyncio.sleep(delay.total_seconds())` and other standard library code. `timedelta` +stores microseconds, so any remaining nanoseconds are dropped, and a duration longer than +`timedelta.max` raises `OverflowError`. `DurationPy.from_timedelta(delta)` creates a `DurationPy` +from a `timedelta` and raises `ValueError` if `delta` is negative. ## [v0.14.0] - October 15, 2026 ### What's New diff --git a/rust/crates/sift_stream_bindings/sift_stream_bindings.pyi b/rust/crates/sift_stream_bindings/sift_stream_bindings.pyi index fdfdc8063..9ca928d10 100644 --- a/rust/crates/sift_stream_bindings/sift_stream_bindings.pyi +++ b/rust/crates/sift_stream_bindings/sift_stream_bindings.pyi @@ -43,6 +43,7 @@ __all__ = [ ] import builtins +import datetime import typing from enum import Enum @@ -186,6 +187,18 @@ class DurationPy: secs: builtins.int nanos: builtins.int def __new__(cls, secs:builtins.int, nanos:builtins.int) -> DurationPy: ... + @staticmethod + def from_timedelta(delta:datetime.timedelta) -> DurationPy: + r""" + Creates a `DurationPy` from a `datetime.timedelta`. Raises `ValueError` if `delta` is + negative. + """ + def to_timedelta(self) -> datetime.timedelta: + r""" + Returns this duration as a `datetime.timedelta`. `timedelta` stores microseconds, so any + remaining nanoseconds are dropped. Raises `OverflowError` if the duration is longer than + `timedelta.max`. + """ @typing.final class FileBackupBuilderPy: diff --git a/rust/crates/sift_stream_bindings/src/stream/retry.rs b/rust/crates/sift_stream_bindings/src/stream/retry.rs index 17a98104e..96ce345f5 100644 --- a/rust/crates/sift_stream_bindings/src/stream/retry.rs +++ b/rust/crates/sift_stream_bindings/src/stream/retry.rs @@ -1,3 +1,4 @@ +use pyo3::exceptions::PyOverflowError; use pyo3::prelude::*; use pyo3_stub_gen::derive::*; use sift_stream::{ @@ -210,6 +211,34 @@ impl DurationPy { pub fn new(secs: u64, nanos: u32) -> Self { Self { secs, nanos } } + + /// Creates a `DurationPy` from a `datetime.timedelta`. Raises `ValueError` if `delta` is + /// negative. + #[staticmethod] + pub fn from_timedelta(delta: std::time::Duration) -> Self { + delta.into() + } + + /// Returns this duration as a `datetime.timedelta`. `timedelta` stores microseconds, so any + /// remaining nanoseconds are dropped. Raises `OverflowError` if the duration is longer than + /// `timedelta.max`. + // pyo3 methods cannot take `self` by value, which clippy expects for `to_*` on a `Copy` type. + #[allow(clippy::wrong_self_convention)] + pub fn to_timedelta(&self) -> PyResult { + // `timedelta.max` is 999,999,999 days plus just under one day. pyo3 raises `ValueError` + // for some longer durations and `OverflowError` for others, so check here to raise + // `OverflowError` consistently, as `timedelta` itself does. + const TIMEDELTA_MAX_SECS: u64 = 999_999_999 * 86_400 + 86_399; + + let duration = std::time::Duration::from(*self); + if duration.as_secs() > TIMEDELTA_MAX_SECS { + return Err(PyOverflowError::new_err(format!( + "duration of {} seconds is longer than timedelta.max", + duration.as_secs() + ))); + } + Ok(duration) + } } #[gen_stub_pymethods] diff --git a/rust/crates/sift_stream_bindings/tests/test_retry.py b/rust/crates/sift_stream_bindings/tests/test_retry.py index c69656480..6eec1a0ab 100644 --- a/rust/crates/sift_stream_bindings/tests/test_retry.py +++ b/rust/crates/sift_stream_bindings/tests/test_retry.py @@ -1,3 +1,5 @@ +from datetime import timedelta + import pytest from sift_stream_bindings import ( DurationPy, @@ -19,6 +21,36 @@ def test_create_duration(self): assert duration.secs == 5 assert duration.nanos == 0 + def test_duration_to_timedelta(self): + """Test converting DurationPy to a timedelta, dropping sub-microsecond nanoseconds.""" + assert DurationPy(2, 500_000_000).to_timedelta() == timedelta(seconds=2, milliseconds=500) + assert DurationPy(0, 0).to_timedelta() == timedelta(0) + assert DurationPy(0, 1_999).to_timedelta() == timedelta(microseconds=1) + + def test_duration_to_timedelta_bounds(self): + """Test that timedelta.max converts and anything longer raises OverflowError.""" + max_secs = timedelta.max.days * 86_400 + timedelta.max.seconds + assert DurationPy(max_secs, 999_999_000).to_timedelta() == timedelta.max + + for secs in (max_secs + 1, 2**63): + with pytest.raises(OverflowError): + DurationPy(secs, 0).to_timedelta() + + def test_duration_from_timedelta(self): + """Test creating DurationPy from a timedelta.""" + duration = DurationPy.from_timedelta(timedelta(days=1, seconds=3, microseconds=250)) + assert duration.secs == 86_403 + assert duration.nanos == 250_000 + + duration = DurationPy.from_timedelta(timedelta(0)) + assert duration.secs == 0 + assert duration.nanos == 0 + + def test_duration_from_negative_timedelta(self): + """Test that a negative timedelta is rejected.""" + with pytest.raises(ValueError): + DurationPy.from_timedelta(timedelta(seconds=-1)) + def test_create_retry_policy(self): """Test creating RetryPolicyPy.""" retry_policy = RetryPolicyPy(10, DurationPy(1, 0), DurationPy(10, 0), 2) @@ -93,6 +125,7 @@ def test_rate_limited_error(self): assert str(err) == message assert err.retry_delay.secs == 2 assert err.retry_delay.nanos == 0 + assert err.retry_delay.to_timedelta() == timedelta(seconds=2) def test_rate_limited_error_without_delay(self): """Test that RateLimitedErrorPy reports None when Sift provides no delay."""