From 413710c10761350fd5741948fd94507e7ecac4a3 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Fri, 23 Oct 2026 19:29:06 -0700 Subject: [PATCH] Complete PyTime conversion fixtures and repair existing helpers (#9317) * Fix rounding and bounds in existing PyTime test helpers Extract the already-exposed conversion helper prerequisite from the broader #9143 surface without adding new test C-API exports or changing time.sleep. Assisted-by: Codex:unknown * Complete PyTime conversion fixtures in _testinternalcapi Stack the eight consumed conversion APIs on the existing-helper prerequisite in #9317. Keep the arithmetic local to the real fixture consumers; do not expose _testcapi or change sleep/lifecycle behavior. Assisted-by: Codex:unknown --------- Co-authored-by: Codex --- crates/vm/src/stdlib/_testinternalcapi.rs | 196 ++++++++++++++++-- .../vm/src/stdlib/_testinternalcapi/pytime.rs | 171 +++++++++++++++ 2 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 crates/vm/src/stdlib/_testinternalcapi/pytime.rs diff --git a/crates/vm/src/stdlib/_testinternalcapi.rs b/crates/vm/src/stdlib/_testinternalcapi.rs index 9e368d446..abd742b4c 100644 --- a/crates/vm/src/stdlib/_testinternalcapi.rs +++ b/crates/vm/src/stdlib/_testinternalcapi.rs @@ -1,5 +1,7 @@ pub(crate) use _testinternalcapi::module_def; +mod pytime; + use core::sync::atomic::{AtomicUsize, Ordering}; static RARE_SET_CLASS: AtomicUsize = AtomicUsize::new(0); @@ -26,6 +28,7 @@ pub(crate) fn note_func_modification() { #[pymodule] mod _testinternalcapi { + use super::pytime::Round; use super::*; use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, @@ -37,7 +40,7 @@ mod _testinternalcapi { common::{hash::PyHash, lock::LazyLock}, dict_inner, frame::FrameObject, - function::{OptionalArg, PyComparisonValue}, + function::{FuncArgs, OptionalArg, PyComparisonValue}, object::{Traverse, TraverseFn}, protocol::{PyIterReturn, PyMappingMethods, PySequenceMethods}, types::{ @@ -45,8 +48,13 @@ mod _testinternalcapi { SelfIter, }, }; + #[cfg(any(unix, windows))] + use rustpython_host_env::time::TimeT; use std::collections::HashMap; + #[cfg(not(any(unix, windows)))] + type TimeT = i64; + // ADAPTIVE_WARMUP_VALUE + 1 #[pyattr] const SPECIALIZATION_THRESHOLD: usize = 2; @@ -69,7 +77,7 @@ mod _testinternalcapi { const SIZEOF_PYOBJECT: usize = core::mem::size_of::(); #[pyattr] - const SIZEOF_TIME_T: usize = 8; + const SIZEOF_TIME_T: usize = core::mem::size_of::(); // JUMP_BACKWARD_INITIAL_VALUE + 1 #[pyattr] @@ -486,24 +494,186 @@ mod _testinternalcapi { } } + fn pytime_args<'a, const N: usize>( + args: &'a FuncArgs, + name: &str, + vm: &VirtualMachine, + ) -> PyResult<&'a [PyObjectRef; N]> { + // These CPython test helpers use METH_VARARGS, not keyword parsing. + if !args.kwargs.is_empty() { + return Err(vm.new_type_error(format!("{name}() takes no keyword arguments"))); + } + args.args.as_slice().try_into().map_err(|_| { + vm.new_type_error(format!( + "function takes exactly {} argument{} ({} given)", + N, + if N == 1 { "" } else { "s" }, + args.args.len() + )) + }) + } + + fn pytime_c_int(object: &PyObject, vm: &VirtualMachine) -> PyResult { + // PyArg_ParseTuple's "i" converts to C long before checking C int bounds. + let value: core::ffi::c_long = object + .try_index(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to C long"))?; + num_traits::ToPrimitive::to_i32(&value).ok_or_else(|| { + vm.new_overflow_error(if value < 0 { + "signed integer is less than minimum" + } else { + "signed integer is greater than maximum" + }) + }) + } + + fn pytime_i64(object: &PyObject, vm: &VirtualMachine) -> PyResult { + object + .try_index(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to C int64_t")) + } + + fn pytime_pair( + value: i64, + denominator: i64, + minimum: i64, + maximum: i64, + clamp: bool, + vm: &VirtualMachine, + ) -> PyResult<(i64, i64)> { + let seconds = value.div_euclid(denominator); + if !(minimum..=maximum).contains(&seconds) { + if !clamp { + return Err(vm.new_overflow_error("timestamp out of range for platform time_t")); + } + return Ok((seconds.clamp(minimum, maximum), 0)); + } + Ok((seconds, value.rem_euclid(denominator))) + } + #[pyfunction(name = "_PyTime_AsTimespec")] - fn pytime_as_timespec(ns: i64) -> (i64, i64) { - let sec = ns.div_euclid(1_000_000_000); - let nsec = ns.rem_euclid(1_000_000_000); - (sec, nsec) + fn pytime_as_timespec(args: FuncArgs, vm: &VirtualMachine) -> PyResult<(i64, i64)> { + let [ns] = pytime_args::<1>(&args, "_PyTime_AsTimespec", vm)?; + pytime_pair( + pytime_i64(ns, vm)?, + 1_000_000_000, + TimeT::MIN as _, + TimeT::MAX as _, + false, + vm, + ) } #[pyfunction(name = "_PyTime_AsTimespec_clamp")] - fn pytime_as_timespec_clamp(ns: i64) -> (i64, i64) { - pytime_as_timespec(ns) + fn pytime_as_timespec_clamp(args: FuncArgs, vm: &VirtualMachine) -> PyResult<(i64, i64)> { + let [ns] = pytime_args::<1>(&args, "_PyTime_AsTimespec_clamp", vm)?; + pytime_pair( + pytime_i64(ns, vm)?, + 1_000_000_000, + TimeT::MIN as _, + TimeT::MAX as _, + true, + vm, + ) } #[pyfunction(name = "_PyTime_AsTimeval_clamp")] - fn pytime_as_timeval_clamp(ns: i64, _rnd: OptionalArg) -> (i64, i64) { - let us = ns.div_euclid(1000); - let sec = us.div_euclid(1_000_000); - let usec = us.rem_euclid(1_000_000); - (sec, usec) + fn pytime_as_timeval_clamp(args: FuncArgs, vm: &VirtualMachine) -> PyResult<(i64, i64)> { + let [ns, round] = pytime_args::<2>(&args, "_PyTime_AsTimeval_clamp", vm)?; + let round = Round::from_int(pytime_c_int(round, vm)?, vm)?; + // Validate rounding before converting the timestamp, as the C wrapper does. + pytime_timeval(pytime_i64(ns, vm)?, round, true, vm) + } + + fn pytime_timeval( + ns: i64, + round: Round, + clamp: bool, + vm: &VirtualMachine, + ) -> PyResult<(i64, i64)> { + let us = round.divide(ns, 1_000); + // Windows timeval uses C long even though the host time_t is 64-bit. + #[cfg(windows)] + let (minimum, maximum): (i64, i64) = + (core::ffi::c_long::MIN as _, core::ffi::c_long::MAX as _); + #[cfg(not(windows))] + let (minimum, maximum): (i64, i64) = (TimeT::MIN as _, TimeT::MAX as _); + pytime_pair(us, 1_000_000, minimum, maximum, clamp, vm) + } + + #[pyfunction(name = "_PyTime_FromSeconds")] + fn pytime_from_seconds(args: FuncArgs, vm: &VirtualMachine) -> PyResult { + let [seconds] = pytime_args::<1>(&args, "_PyTime_FromSeconds", vm)?; + Ok(pytime::from_seconds(pytime_c_int(seconds, vm)?)) + } + + #[pyfunction(name = "_PyTime_FromSecondsObject")] + fn pytime_from_seconds_object(args: FuncArgs, vm: &VirtualMachine) -> PyResult { + let [object, round] = pytime_args::<2>(&args, "_PyTime_FromSecondsObject", vm)?; + let round = Round::from_int(pytime_c_int(round, vm)?, vm)?; + pytime::from_seconds_object(object, round, vm) + } + + #[pyfunction(name = "_PyTime_AsTimeval")] + fn pytime_as_timeval(args: FuncArgs, vm: &VirtualMachine) -> PyResult<(i64, i64)> { + let [ns, round] = pytime_args::<2>(&args, "_PyTime_AsTimeval", vm)?; + let round = Round::from_int(pytime_c_int(round, vm)?, vm)?; + pytime_timeval(pytime_i64(ns, vm)?, round, false, vm) + } + + fn pytime_as_subseconds( + args: FuncArgs, + name: &str, + divisor: i64, + vm: &VirtualMachine, + ) -> PyResult { + let [ns, round] = pytime_args::<2>(&args, name, vm)?; + let round = pytime_c_int(round, vm)?; + // These two C wrappers convert the timestamp before validating the mode. + let ns = pytime_i64(ns, vm)?; + Ok(Round::from_int(round, vm)?.divide(ns, divisor)) + } + + #[pyfunction(name = "_PyTime_AsMilliseconds")] + fn pytime_as_milliseconds(args: FuncArgs, vm: &VirtualMachine) -> PyResult { + pytime_as_subseconds(args, "_PyTime_AsMilliseconds", 1_000_000, vm) + } + + #[pyfunction(name = "_PyTime_AsMicroseconds")] + fn pytime_as_microseconds(args: FuncArgs, vm: &VirtualMachine) -> PyResult { + pytime_as_subseconds(args, "_PyTime_AsMicroseconds", 1_000, vm) + } + + #[pyfunction(name = "_PyTime_ObjectToTime_t")] + fn pytime_object_to_time_t(args: FuncArgs, vm: &VirtualMachine) -> PyResult { + let [object, round] = pytime_args::<2>(&args, "_PyTime_ObjectToTime_t", vm)?; + let round = Round::from_int(pytime_c_int(round, vm)?, vm)?; + pytime::object_to_time_t(object, round, vm) + } + + fn pytime_object_to_denominator( + args: FuncArgs, + name: &str, + denominator: i64, + vm: &VirtualMachine, + ) -> PyResult<(i64, i64)> { + let [object, round] = pytime_args::<2>(&args, name, vm)?; + let round = Round::from_int(pytime_c_int(round, vm)?, vm)?; + pytime::object_to_denominator(object, denominator, round, vm) + } + + #[pyfunction(name = "_PyTime_ObjectToTimeval")] + fn pytime_object_to_timeval(args: FuncArgs, vm: &VirtualMachine) -> PyResult<(i64, i64)> { + pytime_object_to_denominator(args, "_PyTime_ObjectToTimeval", 1_000_000, vm) + } + + #[pyfunction(name = "_PyTime_ObjectToTimespec")] + fn pytime_object_to_timespec(args: FuncArgs, vm: &VirtualMachine) -> PyResult<(i64, i64)> { + pytime_object_to_denominator(args, "_PyTime_ObjectToTimespec", 1_000_000_000, vm) } #[pyfunction] diff --git a/crates/vm/src/stdlib/_testinternalcapi/pytime.rs b/crates/vm/src/stdlib/_testinternalcapi/pytime.rs new file mode 100644 index 000000000..d82aa9180 --- /dev/null +++ b/crates/vm/src/stdlib/_testinternalcapi/pytime.rs @@ -0,0 +1,171 @@ +//! Numeric conversions used by the internal PyTime test helpers. + +#![cfg_attr(target_env = "musl", allow(deprecated))] + +use crate::{AsObject, PyObject, PyResult, VirtualMachine, builtins::PyFloat}; +use num_traits::ToPrimitive; +use rustpython_host_env::time::SEC_TO_NS; + +#[cfg(any(unix, windows))] +pub(super) use rustpython_host_env::time::TimeT; +#[cfg(not(any(unix, windows)))] +pub(super) type TimeT = i64; + +const TIME_T_MIN: i64 = TimeT::MIN as _; +const TIME_T_MAX: i64 = TimeT::MAX as _; +const PYTIME_OVERFLOW: &str = "timestamp out of range for C PyTime_t"; +const TIME_T_OVERFLOW: &str = "timestamp out of range for platform time_t"; + +#[derive(Clone, Copy)] +pub(super) enum Round { + Floor, + Ceiling, + HalfEven, + Up, +} + +impl Round { + pub(super) fn from_int(value: i32, vm: &VirtualMachine) -> PyResult { + match value { + 0 => Ok(Self::Floor), + 1 => Ok(Self::Ceiling), + 2 => Ok(Self::HalfEven), + 3 => Ok(Self::Up), + _ => Err(vm.new_value_error("invalid rounding")), + } + } + + fn round(self, value: f64) -> f64 { + match self { + Self::Floor => value.floor(), + Self::Ceiling => value.ceil(), + Self::HalfEven => value.round_ties_even(), + Self::Up if value >= 0.0 => value.ceil(), + Self::Up => value.floor(), + } + } + + pub(super) fn divide(self, value: i64, divisor: i64) -> i64 { + debug_assert!(divisor > 1); + let quotient = value / divisor; + let remainder = value % divisor; + let round_away = match self { + Self::Floor => remainder < 0, + Self::Ceiling => remainder > 0, + Self::HalfEven => { + let remainder = remainder.abs(); + remainder > divisor / 2 + || (divisor % 2 == 0 && remainder == divisor / 2 && quotient % 2 != 0) + } + Self::Up => remainder != 0, + }; + // Adjust the quotient, not the input: adding to i64::MAX or + // subtracting from i64::MIN before dividing would overflow. + quotient + if round_away { value.signum() } else { 0 } + } +} + +pub(super) fn from_seconds(seconds: i32) -> i64 { + i64::from(seconds) * SEC_TO_NS +} + +fn index_seconds(object: &PyObject, message: &str, vm: &VirtualMachine) -> PyResult { + let seconds = object.try_index(vm).map_err(|err| { + if err.fast_isinstance(vm.ctx.exceptions.overflow_error) { + vm.new_overflow_error(message) + } else { + err + } + })?; + seconds + .as_bigint() + .to_i64() + .ok_or_else(|| vm.new_overflow_error(message)) +} + +fn float_seconds(object: &PyObject, vm: &VirtualMachine) -> PyResult { + // PyFloat_AsDouble bypasses __float__ overrides on float subclasses. + let seconds = match object.downcast_ref::() { + Some(float) => float.to_f64(), + None => object.try_float(vm)?.to_f64(), + }; + if seconds.is_nan() { + return Err(vm.new_value_error("Invalid value NaN (not a number)")); + } + Ok(seconds) +} + +pub(super) fn from_seconds_object( + object: &PyObject, + round: Round, + vm: &VirtualMachine, +) -> PyResult { + // _PyTime_FromSecondsObject uses __index__ before __float__ and bounds + // the converted value to signed 64-bit nanoseconds. + if object.number().is_index() { + index_seconds(object, PYTIME_OVERFLOW, vm)? + .checked_mul(SEC_TO_NS) + .ok_or_else(|| vm.new_overflow_error(PYTIME_OVERFLOW)) + } else { + let nanoseconds = round.round(float_seconds(object, vm)? * SEC_TO_NS as f64); + let minimum = i64::MIN as f64; + // The maximum rounds up to 2**63 as a double, so use an exclusive bound. + if !(minimum..-minimum).contains(&nanoseconds) { + return Err(vm.new_overflow_error(PYTIME_OVERFLOW)); + } + Ok(nanoseconds as i64) + } +} + +fn check_time_t(seconds: i64, vm: &VirtualMachine) -> PyResult { + if !(TIME_T_MIN..=TIME_T_MAX).contains(&seconds) { + return Err(vm.new_overflow_error(TIME_T_OVERFLOW)); + } + Ok(seconds) +} + +fn float_to_time_t(seconds: f64, vm: &VirtualMachine) -> PyResult { + let minimum = TIME_T_MIN as f64; + if !(minimum..-minimum).contains(&seconds) { + return Err(vm.new_overflow_error(TIME_T_OVERFLOW)); + } + Ok(seconds as i64) +} + +pub(super) fn object_to_time_t( + object: &PyObject, + round: Round, + vm: &VirtualMachine, +) -> PyResult { + if object.number().is_index() { + check_time_t(index_seconds(object, TIME_T_OVERFLOW, vm)?, vm) + } else { + float_to_time_t(round.round(float_seconds(object, vm)?), vm) + } +} + +pub(super) fn object_to_denominator( + object: &PyObject, + denominator: i64, + round: Round, + vm: &VirtualMachine, +) -> PyResult<(i64, i64)> { + if object.number().is_index() { + return Ok(( + check_time_t(index_seconds(object, TIME_T_OVERFLOW, vm)?, vm)?, + 0, + )); + } + let value = float_seconds(object, vm)?; + let mut seconds = value.trunc(); + let denominator = denominator as f64; + let mut numerator = round.round(value.fract() * denominator); + if numerator >= denominator { + numerator -= denominator; + seconds += 1.0; + } else if numerator < 0.0 { + numerator += denominator; + seconds -= 1.0; + } + Ok((float_to_time_t(seconds, vm)?, numerator as i64)) +}