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
87 changes: 68 additions & 19 deletions crates/vm/src/stdlib/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ mod decl {

#[cfg(any(unix, windows))]
use crate::builtins::PyBaseExceptionRef;
#[cfg(any(unix, windows))]
use crate::convert::{ToPyException, ToPyObject};
use crate::{
AsObject, Py, PyObjectRef, PyResult, VirtualMachine,
builtins::{PyStr, PyStrRef, PyTypeRef},
builtins::{PyFloat, PyStr, PyStrRef, PyTypeRef},
class::PyClassDef,
common::wtf8::{Wtf8Buf, wtf8_concat},
function::{Either, FuncArgs, OptionalArg, OptionalOption},
types::{PyStructSequence, PyStructSequenceData, struct_sequence_new},
};
Expand All @@ -26,14 +29,10 @@ mod decl {
PyRef,
builtins::{PyNamespace, PyUtf8StrRef},
};
#[cfg(any(unix, windows))]
use crate::{
common::wtf8::Wtf8Buf,
convert::{ToPyException, ToPyObject},
};
use core::time::Duration;
#[cfg(not(any(unix, windows)))]
use jiff::{Timestamp, Zoned, civil::DateTime, tz::TimeZone};
use num_traits::ToPrimitive;
#[cfg(target_os = "wasi")]
use rustpython_host_env::time::ClockId;
#[cfg(any(unix, windows))]
Expand Down Expand Up @@ -128,21 +127,71 @@ mod decl {
fn sleep(object: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> {
vm.audit("time.sleep", || (object.clone(),))?;

let seconds_type_name = object.class().name().to_owned();
let dur = object.try_into_value::<Duration>(vm).map_err(|e| {
if e.class().is(vm.ctx.exceptions.value_error)
&& let Some(s) = e.args().as_slice().first().and_then(|arg| arg.str(vm).ok())
&& s.as_bytes() == b"negative duration"
{
return vm.new_value_error("sleep length must be non-negative");
let overflow = || vm.new_overflow_error("timestamp out of range for C PyTime_t");
let nanoseconds = if let Some(float) = object.downcast_ref::<PyFloat>() {
let seconds = float.to_f64();
if seconds.is_nan() {
return Err(vm.new_value_error("Invalid value NaN (not a number)"));
}
if e.class().is(vm.ctx.exceptions.type_error) {
return vm.new_type_error(format!(
"'{seconds_type_name}' object cannot be interpreted as an integer or float"
));
// _PyTime_ROUND_TIMEOUT rounds away from zero.
let nanoseconds = seconds * SEC_TO_NS as f64;
let nanoseconds = if nanoseconds >= 0.0 {
nanoseconds.ceil()
} else {
nanoseconds.floor()
};
let minimum = i64::MIN as f64;
// The signed maximum rounds up to 2**63 as a double.
if !(minimum..-minimum).contains(&nanoseconds) {
return Err(overflow());
}
e
})?;
nanoseconds as i64
} else {
let seconds = object.try_index(vm).or_else(|e| {
if e.fast_isinstance(vm.ctx.exceptions.overflow_error) {
Err(overflow())
} else if e.fast_isinstance(vm.ctx.exceptions.type_error) {
// CPython's %T reads stored type metadata, preserving surrogates.
let class = object.class();
let name = if let Some(heap_type) = &class.heaptype_ext {
let qualname = heap_type.qualname.read().clone();
let module = if let Some(dict) = class.attributes.as_dict() {
dict.get_item_opt(identifier!(vm, __module__), vm)?
.ok_or_else(|| vm.new_attribute_error("__module__"))?
Comment on lines +159 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve TypeError when heap types have no module

On WebAssembly, time.sleep(sys.stdout) reaches this branch because make_stdstream_object creates the stream class with py_class!, whose new_class(None, ...) leaves the heap-type dictionary without __module__ (crates/wasm/src/wasm_builtins.rs:32 and crates/vm/src/macros.rs:38). This ok_or_else therefore replaces the original unsupported-input TypeError with AttributeError("__module__"); before this change the diagnostic used class().name() and remained a TypeError. Treat an absent module as an unqualified type name, or populate it for every internal heap type.

AGENTS.md reference: AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the missing-module case against exact CPython 3.14.7 using a heap type whose real namespace is exposed through classdictcell, then removing module from that namespace. CPython raises AttributeError("module"), and the published RustPython head does the same. The existing five-case type-dictionary oracle also verifies that lookup exceptions retain their identity. Falling back to an unqualified name here would therefore change verified CPython behavior. The internal WASM construction path is a separate type-metadata question; I have not run WASM locally or changed that construction.

} else {
class.__module__(vm)?
};
match module.downcast_ref::<PyStr>() {
Some(module)
if module.as_wtf8() != "builtins"
&& module.as_wtf8() != "__main__" =>
{
wtf8_concat!(module.as_wtf8(), ".", qualname.as_wtf8())
}
_ => qualname.as_wtf8().to_owned(),
}
} else {
Wtf8Buf::from(class.slot_name().to_owned())
};
Err(vm.new_type_error(wtf8_concat!(
"'",
name,
"' object cannot be interpreted as an integer or float"
)))
} else {
Err(e)
}
})?;
seconds
.as_bigint()
.to_i64()
.and_then(|seconds| seconds.checked_mul(SEC_TO_NS))
.ok_or_else(overflow)?
};
if nanoseconds < 0 {
return Err(vm.new_value_error("sleep length must be non-negative"));
}
let dur = Duration::from_nanos(nanoseconds as u64);

#[cfg(unix)]
{
Expand Down
32 changes: 32 additions & 0 deletions extra_tests/snippets/stdlib_time.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import sys
import time

from testutils import assert_raises

if sys.platform == "win32":
# Record the native runtime before evaluating the range regression below.
print("Windows time runtime:", sys.version, sys.implementation, sys.flags)
Expand Down Expand Up @@ -165,6 +167,36 @@
assert monotonic_elapsed >= 0.01
assert perf_elapsed >= 0.01

# Sleep validates signed nanoseconds before rejecting negative durations.
for duration in (
2**63,
-(2**63) - 1,
2**34,
-(2**34),
2**63 / 1_000_000_000,
-1e10,
float("inf"),
-float("inf"),
):
with assert_raises(OverflowError) as error:
time.sleep(duration)
assert str(error.exception) == "timestamp out of range for C PyTime_t"
assert_raises(ValueError, time.sleep, float("nan"))


class SleepTypeNamespace:
class Invalid:
pass


for module, prefix in (("__main__", ""), ("sleep_test", "sleep_test.")):
SleepTypeNamespace.Invalid.__module__ = module
with assert_raises(TypeError) as error:
time.sleep(SleepTypeNamespace.Invalid())
assert str(error.exception) == (
f"'{prefix}SleepTypeNamespace.Invalid' object cannot be interpreted as an integer or float"
)

# The optional second argument fills the fields that are not part of the
# sequence.
fields = (2024, 1, 2, 3, 4, 5, 6, 7, 0)
Expand Down
Loading