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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ fn main() -> Result<(), DateError> {

// Combine dates and times to work with UTC instants.
let dt = DateTime::from_unix_timestamp(1_700_000_000, 123_456_789)?;
let restored = DateTime::from_unix_timestamp_nanos(dt.unix_timestamp_nanos())?;
assert_eq!(restored, dt);
let later = dt.add_duration(Duration::seconds(90))?;
println!("Delta: {} ns", later.difference(dt).total_nanos());

Expand All @@ -80,6 +82,18 @@ fn main() -> Result<(), DateError> {
}
```

## Lossless Unix timestamps

`DateTime::from_unix_timestamp_nanos(nanos: i128)` reconstructs a UTC instant
from integer nanoseconds without losing subsecond precision. It is the inverse
of `unix_timestamp_nanos()` for every valid `DateTime`, including dates before
1970 and the entire supported year range (`i32::MIN..=i32::MAX`).

Negative values use Euclidean division: `-1` means
`1969-12-31T23:59:59.999999999Z`. Inputs outside the date range return
`DateError::OutOfRange`, including `i128::MIN` and `i128::MAX`.
The existing `from_unix_timestamp(secs, nanos)` API remains available.

## Benchmarks

`cargo bench --bench unix_timestamp -- --quick` runs a quick benchmark comparing `fasttime`'s Unix timestamp conversions to the widely used `time` crate.
Expand Down
14 changes: 14 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,21 @@ print(local_dt) # 2024-01-15T14:30:45.123456789+05:30
timestamp = dt.unix_timestamp() # seconds
timestamp_ns = dt.unix_timestamp_nanos() # nanoseconds
dt_from_ts = fasttime.DateTime.from_unix_timestamp(timestamp, 123_456_789)
restored = fasttime.DateTime.from_unix_timestamp_nanos(timestamp_ns)
assert restored == dt
before_epoch = fasttime.DateTime.from_unix_timestamp_nanos(-1)
assert str(before_epoch) == "1969-12-31T23:59:59.999999999Z"
```

`DateTime.from_unix_timestamp_nanos(nanos: int)` is the exact inverse of
`unix_timestamp_nanos()` for every valid `DateTime`. It accepts integer
nanoseconds in the signed 128-bit range without conversion through `float`.
Negative values correctly preserve the fractional second before the epoch.
The full date range (years `-2147483648` through `2147483647`) is supported.
An integer within `i128` but outside the supported date range raises
`ValueError`; an integer outside `i128` raises `OverflowError`. Non-integer
inputs such as floats raise `TypeError`.

## Pickle serialization

`DateTime` and `Duration` support `pickle.dumps()` and `pickle.loads()` with all
Expand Down Expand Up @@ -157,6 +170,7 @@ dt.difference(other: DateTime) -> Duration # Calculate difference

# Class methods
DateTime.from_unix_timestamp(secs: int, nanos: int = 0) -> DateTime
DateTime.from_unix_timestamp_nanos(nanos: int) -> DateTime
DateTime.now_utc() -> DateTime # Current UTC time
DateTime.parse(s: str) -> DateTime # Parse "YYYY-MM-DDTHH:MM:SS[.fff]Z"
```
Expand Down
17 changes: 11 additions & 6 deletions python/examples/unix_timestamps.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,21 @@ def main():
print("-" * 40)

original = fasttime.DateTime.parse("2024-06-15T14:30:45.123456789Z")
timestamp = original.unix_timestamp()
nanos = original.time.nanosecond

reconstructed = fasttime.DateTime.from_unix_timestamp(timestamp, nanos)
timestamp_nanos = original.unix_timestamp_nanos()
reconstructed = fasttime.DateTime.from_unix_timestamp_nanos(timestamp_nanos)
assert reconstructed == original

print(f"Original: {original}")
print(f"Timestamp: {timestamp}")
print(f"Nanoseconds: {nanos}")
print(f"Timestamp ns: {timestamp_nanos}")
print(f"Reconstructed: {reconstructed}")
print(f"Match: {original == reconstructed}")
before_epoch = fasttime.DateTime.from_unix_timestamp_nanos(-1)
assert str(before_epoch) == "1969-12-31T23:59:59.999999999Z"
assert (
fasttime.DateTime.from_unix_timestamp_nanos(before_epoch.unix_timestamp_nanos())
== before_epoch
)
print(f"-1 ns: {before_epoch}")
print()

# 7. Timestamp Arithmetic
Expand Down
9 changes: 9 additions & 0 deletions python/fasttime/fasttime.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,15 @@ class DateTime:
"""Create a DateTime from Unix timestamp."""
...

@classmethod
def from_unix_timestamp_nanos(cls, nanos: int) -> DateTime:
"""Create from exact integer nanoseconds (i128) since Unix epoch.

Raises ValueError outside the date range, OverflowError outside i128,
and TypeError for non-integer inputs.
"""
...

def unix_timestamp(self) -> int:
"""Get Unix timestamp (seconds since Unix epoch)."""
...
Expand Down
77 changes: 77 additions & 0 deletions python/tests/test_unix_timestamp_nanos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Lossless timestamp reconstruction, including the complete i32 year range."""

import fasttime
import pytest


@pytest.mark.parametrize(
"timestamp, text",
[
(0, "1970-01-01T00:00:00Z"),
(1, "1970-01-01T00:00:00.000000001Z"),
(999_999_999, "1970-01-01T00:00:00.999999999Z"),
(1_000_000_000, "1970-01-01T00:00:01Z"),
(1_000_000_001, "1970-01-01T00:00:01.000000001Z"),
(-1, "1969-12-31T23:59:59.999999999Z"),
(-999_999_999, "1969-12-31T23:59:59.000000001Z"),
(-1_000_000_000, "1969-12-31T23:59:59Z"),
(-1_000_000_001, "1969-12-31T23:59:58.999999999Z"),
(-86_400_000_000_001, "1969-12-30T23:59:59.999999999Z"),
(1_700_000_000_123_456_789, "2023-11-14T22:13:20.123456789Z"),
(-2_208_988_799_123_456_789, "1900-01-01T00:00:00.876543211Z"),
],
)
def test_known_instants(timestamp: int, text: str):
dt = fasttime.DateTime.from_unix_timestamp_nanos(timestamp)
assert dt == fasttime.DateTime.parse(text)
assert dt.unix_timestamp_nanos() == timestamp


@pytest.mark.parametrize("year", [-(2**31), -400, -1, 0, 1, 1969, 2000, 2**31 - 1])
@pytest.mark.parametrize("month, day", [(1, 1), (2, 28), (3, 1), (12, 31)])
@pytest.mark.parametrize("nanos", [0, 1, 123_456_789, 999_999_999])
def test_datetime_round_trip(year: int, month: int, day: int, nanos: int):
original = fasttime.DateTime(
fasttime.Date(year, month, day), fasttime.Time(23, 59, 59, nanos)
)
timestamp = original.unix_timestamp_nanos()
assert fasttime.DateTime.from_unix_timestamp_nanos(timestamp) == original


def test_supported_range_boundaries():
earliest = fasttime.DateTime(fasttime.Date(-(2**31), 1, 1), fasttime.Time(0, 0, 0))
latest = fasttime.DateTime(
fasttime.Date(2**31 - 1, 12, 31), fasttime.Time(23, 59, 59, 999_999_999)
)
for dt in [earliest, latest]:
assert (
fasttime.DateTime.from_unix_timestamp_nanos(dt.unix_timestamp_nanos()) == dt
)
for timestamp in [
earliest.unix_timestamp_nanos() - 1,
latest.unix_timestamp_nanos() + 1,
-(2**127),
2**127 - 1,
-(2**63) * 1_000_000_000,
(2**63 - 1) * 1_000_000_000,
]:
with pytest.raises(ValueError, match="OutOfRange"):
fasttime.DateTime.from_unix_timestamp_nanos(timestamp)


@pytest.mark.parametrize("timestamp", [-(2**127) - 1, 2**127])
def test_python_integer_outside_i128(timestamp: int):
with pytest.raises(OverflowError):
fasttime.DateTime.from_unix_timestamp_nanos(timestamp)


@pytest.mark.parametrize("timestamp", [1.0, "1", None])
def test_requires_integer(timestamp):
with pytest.raises(TypeError):
fasttime.DateTime.from_unix_timestamp_nanos(timestamp)


def test_keyword_argument():
assert str(fasttime.DateTime.from_unix_timestamp_nanos(nanos=-1)) == (
"1969-12-31T23:59:59.999999999Z"
)
31 changes: 31 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,37 @@ impl DateTime {
Ok(DateTime { date, time })
}

/// Build from integer nanoseconds since 1970-01-01T00:00:00Z.
///
/// This is the exact inverse of [`Self::unix_timestamp_nanos`] for every
/// valid `DateTime`. Negative values use Euclidean division, so `-1`
/// represents 1969-12-31T23:59:59.999999999Z.
///
/// # Errors
///
/// Returns [`DateError::OutOfRange`] if the timestamp falls outside the
/// supported date range (years `i32::MIN..=i32::MAX`).
///
/// # Examples
///
/// ```
/// use fasttime::DateTime;
///
/// let dt = DateTime::from_unix_timestamp_nanos(-1).unwrap();
/// assert_eq!(dt.to_string(), "1969-12-31T23:59:59.999999999Z");
/// assert_eq!(dt.unix_timestamp_nanos(), -1);
/// assert_eq!(DateTime::from_unix_timestamp_nanos(dt.unix_timestamp_nanos()), Ok(dt));
/// ```
#[inline]
pub fn from_unix_timestamp_nanos(nanos: i128) -> Result<DateTime, DateError> {
let secs =
i64::try_from(nanos.div_euclid(1_000_000_000)).map_err(|_| DateError::OutOfRange)?;
// The Euclidean remainder is in 0..1_000_000_000, even for i128::MIN.
let subsec_nanos =
i32::try_from(nanos.rem_euclid(1_000_000_000)).map_err(|_| DateError::OutOfRange)?;
Self::from_unix_timestamp(secs, subsec_nanos)
}

/// Seconds since Unix epoch (1970-01-01T00:00:00Z).
#[inline]
#[must_use]
Expand Down
21 changes: 21 additions & 0 deletions src/python.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,27 @@ impl PyDateTime {
.map_err(|e| PyValueError::new_err(format!("Invalid timestamp: {e:?}")))
}

/// Create a DateTime from integer nanoseconds since Unix epoch.
///
/// Args:
/// nanos: Total nanoseconds since 1970-01-01T00:00:00Z (signed i128).
/// Negative values represent instants before the epoch.
///
/// Returns:
/// DateTime: A new DateTime with exact nanosecond precision.
///
/// Raises:
/// ValueError: If the timestamp is outside the supported date range.
/// OverflowError: If nanos is outside the signed 128-bit integer range.
/// TypeError: If nanos is not an integer.
#[classmethod]
#[pyo3(name = "from_unix_timestamp_nanos")]
fn from_unix_timestamp_nanos(_cls: &Bound<'_, PyType>, nanos: i128) -> PyResult<Self> {
DateTime::from_unix_timestamp_nanos(nanos)
.map(PyDateTime)
.map_err(|e| PyValueError::new_err(format!("Invalid timestamp: {e:?}")))
}

/// Get Unix timestamp (seconds since Unix epoch).
#[pyo3(name = "unix_timestamp")]
fn unix_timestamp(&self) -> i64 {
Expand Down
134 changes: 134 additions & 0 deletions tests/unix_timestamp_nanos.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
use fasttime::{Date, DateError, DateTime, Time};

#[test]
fn nanosecond_timestamps_match_known_instants() {
let cases = [
(0, "1970-01-01T00:00:00Z"),
(1, "1970-01-01T00:00:00.000000001Z"),
(999_999_999, "1970-01-01T00:00:00.999999999Z"),
(1_000_000_000, "1970-01-01T00:00:01Z"),
(1_000_000_001, "1970-01-01T00:00:01.000000001Z"),
(-1, "1969-12-31T23:59:59.999999999Z"),
(-999_999_999, "1969-12-31T23:59:59.000000001Z"),
(-1_000_000_000, "1969-12-31T23:59:59Z"),
(-1_000_000_001, "1969-12-31T23:59:58.999999999Z"),
(-86_400_000_000_001, "1969-12-30T23:59:59.999999999Z"),
(1_700_000_000_123_456_789, "2023-11-14T22:13:20.123456789Z"),
(-2_208_988_799_123_456_789, "1900-01-01T00:00:00.876543211Z"),
];
for (timestamp, text) in cases {
let expected: DateTime = text.parse().unwrap();
let actual = DateTime::from_unix_timestamp_nanos(timestamp).unwrap();
assert_eq!(actual, expected, "timestamp {timestamp}");
assert_eq!(actual.unix_timestamp_nanos(), timestamp);
}
}

fn date_limits() -> (DateTime, DateTime) {
(
DateTime::new(
Date::from_ymd(i32::MIN, 1, 1).unwrap(),
Time::from_hms_nano(0, 0, 0, 0).unwrap(),
),
DateTime::new(
Date::from_ymd(i32::MAX, 12, 31).unwrap(),
Time::from_hms_nano(23, 59, 59, 999_999_999).unwrap(),
),
)
}

#[test]
fn nanosecond_timestamp_date_limits_and_overflow() {
let (min, max) = date_limits();
for dt in [min, max] {
assert_eq!(
DateTime::from_unix_timestamp_nanos(dt.unix_timestamp_nanos()),
Ok(dt)
);
}
for timestamp in [
min.unix_timestamp_nanos() - 1,
max.unix_timestamp_nanos() + 1,
i128::MIN,
i128::MAX,
i128::from(i64::MIN) * 1_000_000_000,
i128::from(i64::MAX) * 1_000_000_000,
] {
assert_eq!(
DateTime::from_unix_timestamp_nanos(timestamp),
Err(DateError::OutOfRange),
"timestamp {timestamp}"
);
}
}

#[test]
fn nanosecond_timestamp_round_trips_across_entire_date_range() {
// Calendar-first samples cover both i32 year limits, BCE, leap days,
// centuries, and subsecond values that floating-point timestamps lose.
for year in [i32::MIN, -400, -1, 0, 1, 1900, 1969, 1970, 2000, i32::MAX] {
for month in 1..=12 {
for day in [1, 28, 29, 30, 31] {
let Ok(date) = Date::from_ymd(year, month, day) else {
continue;
};
for (h, m, s, ns) in [
(0, 0, 0, 0),
(0, 0, 0, 1),
(12, 34, 56, 123_456_789),
(23, 59, 59, 999_999_999),
] {
let dt = DateTime::new(date, Time::from_hms_nano(h, m, s, ns).unwrap());
assert_eq!(
DateTime::from_unix_timestamp_nanos(dt.unix_timestamp_nanos()),
Ok(dt)
);
}
}
}
}

// Timestamp-first samples span the full supported interval (well beyond
// i64 nanoseconds), including its endpoints, without a random dependency.
let (min, max) = date_limits();
let start = min.unix_timestamp_nanos();
let span = max.unix_timestamp_nanos() - start;
for i in 0..=16_384 {
let timestamp = start + span * i / 16_384;
let dt = DateTime::from_unix_timestamp_nanos(timestamp).unwrap();
assert_eq!(dt.unix_timestamp_nanos(), timestamp);
}
}

#[test]
fn nanosecond_timestamps_match_time_crate() {
let start = time::Date::MIN
.midnight()
.assume_utc()
.unix_timestamp_nanos();
let end = time::Date::MAX
.with_hms_nano(23, 59, 59, 999_999_999)
.unwrap()
.assume_utc()
.unix_timestamp_nanos();
let span = end - start;
for i in 0..=16_384 {
let timestamp = start + span * i / 16_384;
let reference = time::OffsetDateTime::from_unix_timestamp_nanos(timestamp).unwrap();
let actual = DateTime::from_unix_timestamp_nanos(timestamp).unwrap();
assert_eq!(
actual.date,
Date::from_ymd(reference.year(), reference.month().into(), reference.day()).unwrap()
);
assert_eq!(
actual.time,
Time::from_hms_nano(
reference.hour(),
reference.minute(),
reference.second(),
reference.nanosecond()
)
.unwrap()
);
}
}
Loading