From 8e459ab102ed5f4a06023895d58bea181cebde80 Mon Sep 17 00:00:00 2001 From: Yehor Smoliakov Date: Fri, 16 Oct 2026 07:21:00 -0700 Subject: [PATCH] feat: add lossless nanosecond timestamp constructor (#36) --- README.md | 14 +++ python/README.md | 14 +++ python/examples/unix_timestamps.py | 17 ++- python/fasttime/fasttime.pyi | 9 ++ python/tests/test_unix_timestamp_nanos.py | 77 +++++++++++++ src/lib.rs | 31 +++++ src/python.rs | 21 ++++ tests/unix_timestamp_nanos.rs | 134 ++++++++++++++++++++++ 8 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 python/tests/test_unix_timestamp_nanos.py create mode 100644 tests/unix_timestamp_nanos.rs diff --git a/README.md b/README.md index 6a342d6..d017b39 100644 --- a/README.md +++ b/README.md @@ -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()); @@ -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. diff --git a/python/README.md b/python/README.md index b5c3dba..d98fca4 100644 --- a/python/README.md +++ b/python/README.md @@ -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 @@ -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" ``` diff --git a/python/examples/unix_timestamps.py b/python/examples/unix_timestamps.py index f237dab..72a0a52 100644 --- a/python/examples/unix_timestamps.py +++ b/python/examples/unix_timestamps.py @@ -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 diff --git a/python/fasttime/fasttime.pyi b/python/fasttime/fasttime.pyi index 8a32789..f929a09 100644 --- a/python/fasttime/fasttime.pyi +++ b/python/fasttime/fasttime.pyi @@ -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).""" ... diff --git a/python/tests/test_unix_timestamp_nanos.py b/python/tests/test_unix_timestamp_nanos.py new file mode 100644 index 0000000..90929a8 --- /dev/null +++ b/python/tests/test_unix_timestamp_nanos.py @@ -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" + ) diff --git a/src/lib.rs b/src/lib.rs index 30cfe51..4a77c67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 { + 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] diff --git a/src/python.rs b/src/python.rs index ad328ae..9feeb15 100644 --- a/src/python.rs +++ b/src/python.rs @@ -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 { + 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 { diff --git a/tests/unix_timestamp_nanos.rs b/tests/unix_timestamp_nanos.rs new file mode 100644 index 0000000..4588bbf --- /dev/null +++ b/tests/unix_timestamp_nanos.rs @@ -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() + ); + } +}