From 54dfc8d584d0b3ea1153eebb2d7b010d6137ed88 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Sat, 10 Oct 2026 09:44:08 +0900 Subject: [PATCH] Support final Python 3.15 Windows negative timestamps Use FILETIME for pre-epoch conversions and preserve precise Windows timestamp errors. Assisted-by: OpenAI Codex:model identifier unavailable --- crates/host_env/src/time.rs | 73 +++++++++++++++++++++++-- crates/vm/src/exceptions.rs | 12 ++++ extra_tests/snippets/stdlib_datetime.py | 21 +++++-- extra_tests/snippets/stdlib_time.py | 27 ++++++++- 4 files changed, 118 insertions(+), 15 deletions(-) diff --git a/crates/host_env/src/time.rs b/crates/host_env/src/time.rs index 5cac2ff48..5631f167d 100644 --- a/crates/host_env/src/time.rs +++ b/crates/host_env/src/time.rs @@ -145,23 +145,84 @@ pub fn current_time_t() -> TimeT { } #[cfg(windows)] -#[cfg_attr(target_env = "musl", allow(deprecated))] -pub fn gmtime_from_timestamp(when: TimeT) -> std::io::Result { +#[derive(Debug)] +pub enum TimestampError { + FiletimeOutOfRange, + Os(std::io::Error), +} + +#[cfg(windows)] +fn windows_filetime(when: TimeT, local: bool) -> Result { + use windows_sys::Win32::{ + Foundation::{FILETIME, SYSTEMTIME}, + System::Time::{FileTimeToSystemTime, SystemTimeToTzSpecificLocalTime}, + }; + + const SECS_BETWEEN_EPOCHS: i64 = 11_644_473_600; + if when < -SECS_BETWEEN_EPOCHS { + return Err(TimestampError::FiletimeOutOfRange); + } + // Only negative timestamps reach this fallback, so the FILETIME cannot overflow. + debug_assert!(when < 0); + let ticks = ((when + SECS_BETWEEN_EPOCHS) as u64) * 10_000_000; + let filetime = FILETIME { + dwLowDateTime: ticks as u32, + dwHighDateTime: (ticks >> 32) as u32, + }; + let mut utc = SYSTEMTIME::default(); + if unsafe { FileTimeToSystemTime(&filetime, &mut utc) } == 0 { + return Err(TimestampError::Os(std::io::Error::last_os_error())); + } + let time = if local { + let mut result = SYSTEMTIME::default(); + // Unlike FileTimeToLocalFileTime, this applies DST rules for the timestamp. + if unsafe { SystemTimeToTzSpecificLocalTime(core::ptr::null(), &utc, &mut result) } == 0 { + return Err(TimestampError::Os(std::io::Error::last_os_error())); + } + result + } else { + utc + }; + const DAYS_BEFORE_MONTH: [i32; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + let year = i32::from(time.wYear); + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let yday = DAYS_BEFORE_MONTH[usize::from(time.wMonth - 1)] + i32::from(time.wDay) - 1 + + i32::from(time.wMonth > 2 && leap); + Ok(libc::tm { + tm_year: year - 1900, + tm_mon: i32::from(time.wMonth) - 1, + tm_mday: i32::from(time.wDay), + tm_hour: i32::from(time.wHour), + tm_min: i32::from(time.wMinute), + tm_sec: i32::from(time.wSecond), + tm_wday: i32::from(time.wDayOfWeek), + tm_yday: yday, + tm_isdst: if local { -1 } else { 0 }, + }) +} + +#[cfg(windows)] +pub fn gmtime_from_timestamp(when: TimeT) -> Result { + if when < 0 { + return windows_filetime(when, false); + } let mut out = core::mem::MaybeUninit::::uninit(); let err = unsafe { _gmtime64_s(out.as_mut_ptr(), &when) }; if err != 0 { - return Err(crate::os::io_error_from_errno(err)); + return Err(TimestampError::Os(crate::os::io_error_from_errno(err))); } Ok(unsafe { out.assume_init() }) } #[cfg(windows)] -#[cfg_attr(target_env = "musl", allow(deprecated))] -pub fn localtime_from_timestamp(when: TimeT) -> std::io::Result { +pub fn localtime_from_timestamp(when: TimeT) -> Result { + if when < 0 { + return windows_filetime(when, true); + } let mut out = core::mem::MaybeUninit::::uninit(); let err = unsafe { _localtime64_s(out.as_mut_ptr(), &when) }; if err != 0 { - return Err(crate::os::io_error_from_errno(err)); + return Err(TimestampError::Os(crate::os::io_error_from_errno(err))); } Ok(unsafe { out.assume_init() }) } diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index dbbdb9204..f7869fccd 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -1576,6 +1576,18 @@ impl ToPyException for rustpython_host_env::socket::AncillaryPackError { } } +#[cfg(windows)] +impl ToPyException for rustpython_host_env::time::TimestampError { + fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { + match self { + Self::FiletimeOutOfRange => { + vm.new_overflow_error("timestamp out of range for Windows FILETIME") + } + Self::Os(error) => error.to_pyexception(vm), + } + } +} + #[cfg(any(unix, windows))] impl ToPyException for rustpython_host_env::time::CheckedTmError { fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { diff --git a/extra_tests/snippets/stdlib_datetime.py b/extra_tests/snippets/stdlib_datetime.py index bf2a7852a..817f92371 100644 --- a/extra_tests/snippets/stdlib_datetime.py +++ b/extra_tests/snippets/stdlib_datetime.py @@ -3951,12 +3951,21 @@ def test_timestamp_errno(): raise AssertionError( "Windows localtime accepted a timestamp beyond the CRT range" ) - try: - datetime_module.datetime.fromtimestamp(-86400, datetime_module.timezone.utc) - except OSError as error: - assert error.errno == errno.EINVAL - else: - raise AssertionError("Windows gmtime accepted an out-of-range timestamp") + assert datetime_module.datetime.fromtimestamp( + -86400, datetime_module.timezone.utc + ) == datetime_module.datetime(1969, 12, 31, tzinfo=datetime_module.timezone.utc) + assert datetime_module.datetime.fromtimestamp( + -11_644_473_600, datetime_module.timezone.utc + ) == datetime_module.datetime(1601, 1, 1, tzinfo=datetime_module.timezone.utc) + for zone in (None, datetime_module.timezone.utc): + try: + datetime_module.datetime.fromtimestamp(-11_644_473_601, zone) + except OverflowError as error: + assert str(error) == "timestamp out of range for Windows FILETIME" + else: + raise AssertionError( + "pre-FILETIME datetime did not raise OverflowError" + ) test_astimezone_subclass_state() diff --git a/extra_tests/snippets/stdlib_time.py b/extra_tests/snippets/stdlib_time.py index dd43d3f0b..9359c2127 100644 --- a/extra_tests/snippets/stdlib_time.py +++ b/extra_tests/snippets/stdlib_time.py @@ -73,9 +73,8 @@ if sys.platform == "win32": import errno for converter in (time.gmtime, time.localtime, time.ctime): - # Python 3.14 preserves UCRT conversion failures as OSError. Use a - # negative timestamp below gmtime's near-epoch allowance in UCRT. - for timestamp in (-86400, 2**40, 2**63 - 1, -(2**63), -float(2**63)): + # Non-negative timestamps still use UCRT and preserve errno. + for timestamp in (2**40, 2**63 - 1): try: converter(timestamp) except OSError as error: @@ -83,6 +82,28 @@ if sys.platform == "win32": else: raise AssertionError("UCRT timestamp failure did not raise OSError") + # Negative timestamps use FILETIME, whose epoch is 1601-01-01. + for timestamp in (-11_644_473_601, -(2**63), -float(2**63)): + try: + converter(timestamp) + except OverflowError as error: + assert str(error) == "timestamp out of range for Windows FILETIME" + else: + raise AssertionError( + "pre-FILETIME timestamp did not raise OverflowError" + ) + + assert time.gmtime(-11_644_473_600) == (1601, 1, 1, 0, 0, 0, 0, 1, 0) + assert time.gmtime(-86400) == (1969, 12, 31, 0, 0, 0, 2, 365, 0) + assert time.gmtime(-1) == (1969, 12, 31, 23, 59, 59, 2, 365, 0) + # Leap-year accounting uses the historical date, including century rules. + assert time.gmtime(-2_203_891_200).tm_yday == 60 # 1900-03-01 + assert time.gmtime(-2_330_035_200).tm_yday == 61 # 1896-03-01 + for timestamp in (-86400, -43201, -1): + local = time.localtime(timestamp) + assert local.tm_isdst == -1 + assert time.ctime(timestamp) == time.asctime(local) + x = time.gmtime(1000) assert x.tm_year == 1970