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
12 changes: 12 additions & 0 deletions rust/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions rust/crates/sift_stream_bindings/sift_stream_bindings.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ __all__ = [
]

import builtins
import datetime
import typing
from enum import Enum

Expand Down Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions rust/crates/sift_stream_bindings/src/stream/retry.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use pyo3::exceptions::PyOverflowError;
use pyo3::prelude::*;
use pyo3_stub_gen::derive::*;
use sift_stream::{
Expand Down Expand Up @@ -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<std::time::Duration> {
// `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]
Expand Down
33 changes: 33 additions & 0 deletions rust/crates/sift_stream_bindings/tests/test_retry.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from datetime import timedelta

import pytest
from sift_stream_bindings import (
DurationPy,
Expand All @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down
Loading